diff --git a/.changeset/tiny-glasses-play.md b/.changeset/tiny-glasses-play.md new file mode 100644 index 000000000000..1515d63ee794 --- /dev/null +++ b/.changeset/tiny-glasses-play.md @@ -0,0 +1,6 @@ +--- +'@astrojs/image': minor +--- + +- Fixes two bugs that were blocking SSR support when deployed to a hosting service +- The built-in `sharp` service now automatically rotates images based on EXIF data diff --git a/.changeset/two-hounds-sort.md b/.changeset/two-hounds-sort.md new file mode 100644 index 000000000000..b6be5ea04eb6 --- /dev/null +++ b/.changeset/two-hounds-sort.md @@ -0,0 +1,20 @@ +--- +'astro': minor +'@astrojs/markdown-component': minor +'@astrojs/markdown-remark': minor +--- + +The use of components and JSX expressions in Markdown are no longer supported by default. + +For long term support, migrate to the `@astrojs/mdx` integration for MDX support (including `.mdx` pages!). + +Not ready to migrate to MDX? Add the legacy flag to your Astro config to re-enable the previous Markdown support. + +```js +// https://astro.build/config +export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + } +}); +``` diff --git a/packages/astro/e2e/fixtures/preact-compat-component/astro.config.mjs b/packages/astro/e2e/fixtures/preact-compat-component/astro.config.mjs index 7d2c8a855d1d..2cd377763753 100644 --- a/packages/astro/e2e/fixtures/preact-compat-component/astro.config.mjs +++ b/packages/astro/e2e/fixtures/preact-compat-component/astro.config.mjs @@ -3,5 +3,8 @@ import preact from '@astrojs/preact'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [preact({ compat: true })], }); diff --git a/packages/astro/e2e/fixtures/preact-component/astro.config.mjs b/packages/astro/e2e/fixtures/preact-component/astro.config.mjs index 7a8aef52144b..bcaa451eb910 100644 --- a/packages/astro/e2e/fixtures/preact-component/astro.config.mjs +++ b/packages/astro/e2e/fixtures/preact-component/astro.config.mjs @@ -4,5 +4,8 @@ import mdx from '@astrojs/mdx'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [preact(), mdx()], }); diff --git a/packages/astro/e2e/fixtures/react-component/astro.config.mjs b/packages/astro/e2e/fixtures/react-component/astro.config.mjs index 5c044b69d552..badddf1d3922 100644 --- a/packages/astro/e2e/fixtures/react-component/astro.config.mjs +++ b/packages/astro/e2e/fixtures/react-component/astro.config.mjs @@ -4,5 +4,8 @@ import mdx from '@astrojs/mdx'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [react(), mdx()], }); diff --git a/packages/astro/e2e/fixtures/solid-component/astro.config.mjs b/packages/astro/e2e/fixtures/solid-component/astro.config.mjs index f527c69b4ab3..35d38c8f1566 100644 --- a/packages/astro/e2e/fixtures/solid-component/astro.config.mjs +++ b/packages/astro/e2e/fixtures/solid-component/astro.config.mjs @@ -4,5 +4,8 @@ import solid from '@astrojs/solid-js'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [solid(), mdx()], }); diff --git a/packages/astro/e2e/fixtures/svelte-component/astro.config.mjs b/packages/astro/e2e/fixtures/svelte-component/astro.config.mjs index bc5c6c9bb9a7..99f557d4373b 100644 --- a/packages/astro/e2e/fixtures/svelte-component/astro.config.mjs +++ b/packages/astro/e2e/fixtures/svelte-component/astro.config.mjs @@ -4,5 +4,8 @@ import mdx from '@astrojs/mdx'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [svelte(), mdx()], }); diff --git a/packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md b/packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md deleted file mode 100644 index e4c6b6bc9d66..000000000000 --- a/packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: "Markdown + Tailwind" -setup: | - import Button from '../components/Button.astro'; - import Complex from '../components/Complex.astro'; ---- - -
- - -
\ No newline at end of file diff --git a/packages/astro/e2e/fixtures/vue-component/astro.config.mjs b/packages/astro/e2e/fixtures/vue-component/astro.config.mjs index 9a3f1272790c..84c024e68fe9 100644 --- a/packages/astro/e2e/fixtures/vue-component/astro.config.mjs +++ b/packages/astro/e2e/fixtures/vue-component/astro.config.mjs @@ -4,6 +4,9 @@ import mdx from '@astrojs/mdx'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [ mdx(), vue({ diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2e6b167c0c23..6e5068e8940a 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -527,27 +527,6 @@ export interface AstroUserConfig { */ drafts?: boolean; - /** - * @docs - * @name markdown.mode - * @type {'md' | 'mdx'} - * @default `mdx` - * @description - * Control whether Markdown processing is done using MDX or not. - * - * MDX processing enables you to use JSX inside your Markdown files. However, there may be instances where you don't want this behavior, and would rather use a "vanilla" Markdown processor. This field allows you to control that behavior. - * - * ```js - * { - * markdown: { - * // Example: Use non-MDX processor for Markdown files - * mode: 'md', - * } - * } - * ``` - */ - mode?: 'md' | 'mdx'; - /** * @docs * @name markdown.shikiConfig @@ -716,6 +695,16 @@ export interface AstroUserConfig { buildOptions?: never; /** @deprecated `devOptions` has been renamed to `server` */ devOptions?: never; + + legacy?: { + /** + * Enable components and JSX expressions in markdown + * Consider our MDX integration before applying this flag! + * @see https://docs.astro.build/en/guides/integrations-guide/mdx/ + * Default: false + */ + astroFlavoredMarkdown?: boolean; + }; } // NOTE(fks): We choose to keep our hand-generated AstroUserConfig interface so that diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 2a181fe7f305..55cd4e8c174f 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -213,7 +213,10 @@ async function generatePath( adapterName: undefined, links, logging, - markdown: astroConfig.markdown, + markdown: { + ...astroConfig.markdown, + isAstroFlavoredMd: astroConfig.legacy.astroFlavoredMarkdown, + }, mod, mode: opts.mode, origin, diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index 2c3b88a1187b..d8e6ff728866 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -153,7 +153,10 @@ function buildManifest( routes, site: astroConfig.site, base: astroConfig.base, - markdown: astroConfig.markdown, + markdown: { + ...astroConfig.markdown, + isAstroFlavoredMd: astroConfig.legacy.astroFlavoredMarkdown, + }, pageMap: null as any, renderers: [], entryModules, diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index bbfad04396a9..ff6b1557c83e 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -50,6 +50,9 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { rehypePlugins: [], }, vite: {}, + legacy: { + astroFlavoredMarkdown: false, + }, }; async function resolvePostcssConfig(inlineOptions: any, root: URL): Promise { @@ -172,9 +175,6 @@ export const AstroConfigSchema = z.object({ .default({}), markdown: z .object({ - // NOTE: "mdx" allows us to parse/compile Astro components in markdown. - // TODO: This should probably be updated to something more like "md" | "astro" - mode: z.enum(['md', 'mdx']).default('mdx'), drafts: z.boolean().default(false), syntaxHighlight: z .union([z.literal('shiki'), z.literal('prism'), z.literal(false)]) @@ -212,6 +212,15 @@ export const AstroConfigSchema = z.object({ vite: z .custom((data) => data instanceof Object && !Array.isArray(data)) .default(ASTRO_CONFIG_DEFAULTS.vite), + legacy: z + .object({ + astroFlavoredMarkdown: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.legacy.astroFlavoredMarkdown), + }) + .optional() + .default({}), }); /** Turn raw config values into normalized values */ diff --git a/packages/astro/src/core/render/dev/index.ts b/packages/astro/src/core/render/dev/index.ts index 428c30edf10c..df0d131778a6 100644 --- a/packages/astro/src/core/render/dev/index.ts +++ b/packages/astro/src/core/render/dev/index.ts @@ -173,7 +173,10 @@ export async function render( links, styles, logging, - markdown: astroConfig.markdown, + markdown: { + ...astroConfig.markdown, + isAstroFlavoredMd: astroConfig.legacy.astroFlavoredMarkdown, + }, mod, mode, origin, diff --git a/packages/astro/src/jsx-runtime/index.ts b/packages/astro/src/jsx-runtime/index.ts index fdabff3242e4..20630a82a0d4 100644 --- a/packages/astro/src/jsx-runtime/index.ts +++ b/packages/astro/src/jsx-runtime/index.ts @@ -29,23 +29,24 @@ export function transformSlots(vnode: AstroVNode) { delete child.props.slot; delete vnode.props.children; } - if (!Array.isArray(vnode.props.children)) return; - // Handle many children with slot attributes - vnode.props.children = vnode.props.children - .map((child) => { - if (!isVNode(child)) return child; - if (!('slot' in child.props)) return child; - const name = toSlotName(child.props.slot); - if (Array.isArray(slots[name])) { - slots[name].push(child); - } else { - slots[name] = [child]; - slots[name]['$$slot'] = true; - } - delete child.props.slot; - return Empty; - }) - .filter((v) => v !== Empty); + if (Array.isArray(vnode.props.children)) { + // Handle many children with slot attributes + vnode.props.children = vnode.props.children + .map((child) => { + if (!isVNode(child)) return child; + if (!('slot' in child.props)) return child; + const name = toSlotName(child.props.slot); + if (Array.isArray(slots[name])) { + slots[name].push(child); + } else { + slots[name] = [child]; + slots[name]['$$slot'] = true; + } + delete child.props.slot; + return Empty; + }) + .filter((v) => v !== Empty); + } Object.assign(vnode.props, slots); } diff --git a/packages/astro/src/runtime/server/jsx.ts b/packages/astro/src/runtime/server/jsx.ts index 179fb172f29c..687d0c9b9968 100644 --- a/packages/astro/src/runtime/server/jsx.ts +++ b/packages/astro/src/runtime/server/jsx.ts @@ -19,6 +19,9 @@ let consoleFilterRefs = 0; export async function renderJSX(result: SSRResult, vnode: any): Promise { switch (true) { case vnode instanceof HTMLString: + if (vnode.toString().trim() === '') { + return ''; + } return vnode; case typeof vnode === 'string': return markHTMLString(escapeHTML(vnode)); @@ -55,6 +58,9 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise { } if (vnode.type) { + if (typeof vnode.type === 'function' && (vnode.type as any)['astro:renderer']) { + skipAstroJSXCheck.add(vnode.type); + } if (typeof vnode.type === 'function' && vnode.props['server:root']) { const output = await vnode.type(vnode.props ?? {}); return await renderJSX(result, output); @@ -76,7 +82,7 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise { } const { children = null, ...props } = vnode.props ?? {}; - const slots: Record = { + const _slots: Record = { default: [], }; function extractSlots(child: any): any { @@ -84,19 +90,34 @@ export async function renderJSX(result: SSRResult, vnode: any): Promise { return child.map((c) => extractSlots(c)); } if (!isVNode(child)) { - return slots.default.push(child); + _slots.default.push(child); + return; } if ('slot' in child.props) { - slots[child.props.slot] = [...(slots[child.props.slot] ?? []), child]; + _slots[child.props.slot] = [...(_slots[child.props.slot] ?? []), child]; delete child.props.slot; return; } - slots.default.push(child); + _slots.default.push(child); } extractSlots(children); - for (const [key, value] of Object.entries(slots)) { - slots[key] = () => renderJSX(result, value); + for (const [key, value] of Object.entries(props)) { + if (value['$$slot']) { + _slots[key] = value; + delete props[key]; + } + } + const slotPromises = []; + const slots: Record = {}; + for (const [key, value] of Object.entries(_slots)) { + slotPromises.push( + renderJSX(result, value).then((output) => { + if (output.toString().trim().length === 0) return; + slots[key] = () => output; + }) + ); } + await Promise.all(slotPromises); let output: string | AsyncIterable; if (vnode.type === ClientOnlyPlaceholder && vnode.props['client:only']) { diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index 027407d346fe..fdd009f8ad75 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -137,7 +137,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { const filename = normalizeFilename(id); const source = await fs.promises.readFile(filename, 'utf8'); const renderOpts = config.markdown; - const isMDX = renderOpts.mode === 'mdx'; + const isAstroFlavoredMd = config.legacy.astroFlavoredMarkdown; const fileUrl = new URL(`file://${filename}`); const isPage = fileUrl.pathname.startsWith(resolvePages(config).pathname); @@ -149,7 +149,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { // Turn HTML comments into JS comments while preventing nested `*/` sequences // from ending the JS comment by injecting a zero-width space // Inside code blocks, this is removed during renderMarkdown by the remark-escape plugin. - if (isMDX) { + if (isAstroFlavoredMd) { markdownContent = markdownContent.replace( /<\s*!--([^-->]*)(.*?)-->/gs, (whole) => `{/*${whole.replace(/\*\//g, '*\u200b/')}*/}` @@ -159,6 +159,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { let renderResult = await renderMarkdown(markdownContent, { ...renderOpts, fileURL: fileUrl, + isAstroFlavoredMd, } as any); let { code: astroResult, metadata } = renderResult; const { layout = '', components = '', setup = '', ...content } = frontmatter; @@ -168,9 +169,9 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { const prelude = `--- import Slugger from 'github-slugger'; ${layout ? `import Layout from '${layout}';` : ''} -${isMDX && components ? `import * from '${components}';` : ''} +${isAstroFlavoredMd && components ? `import * from '${components}';` : ''} ${hasInjectedScript ? `import '${PAGE_SSR_SCRIPT_ID}';` : ''} -${isMDX ? setup : ''} +${isAstroFlavoredMd ? setup : ''} const slugger = new Slugger(); function $$slug(value) { @@ -178,7 +179,7 @@ function $$slug(value) { } const $$content = ${JSON.stringify( - isMDX + isAstroFlavoredMd ? content : // Avoid stripping "setup" and "components" // in plain MD mode @@ -186,11 +187,11 @@ const $$content = ${JSON.stringify( )} ---`; const imports = `${layout ? `import Layout from '${layout}';` : ''} -${isMDX ? setup : ''}`.trim(); +${isAstroFlavoredMd ? setup : ''}`.trim(); // Wrap with set:html fragment to skip // JSX expressions and components in "plain" md mode - if (!isMDX) { + if (!isAstroFlavoredMd) { astroResult = ``; } diff --git a/packages/astro/test/astro-markdown-md-mode.test.js b/packages/astro/test/astro-markdown-md-mode.test.js index 46056605f073..89f2a26abdf2 100644 --- a/packages/astro/test/astro-markdown-md-mode.test.js +++ b/packages/astro/test/astro-markdown-md-mode.test.js @@ -38,7 +38,6 @@ describe('Astro Markdown - plain MD mode', () => { root: './fixtures/astro-markdown-md-mode/', markdown: { syntaxHighlight: 'prism', - mode: 'md', }, }); await fixture.build(); diff --git a/packages/astro/test/fixtures/astro-markdown-css/astro.config.mjs b/packages/astro/test/fixtures/astro-markdown-css/astro.config.mjs index eb6636d67f65..410c20408698 100644 --- a/packages/astro/test/fixtures/astro-markdown-css/astro.config.mjs +++ b/packages/astro/test/fixtures/astro-markdown-css/astro.config.mjs @@ -3,7 +3,7 @@ import { defineConfig } from 'astro/config'; // https://astro.build/config export default defineConfig({ legacy: { - astroFlavoredMarkdown: true + astroFlavoredMarkdown: true, }, integrations: [] }); diff --git a/packages/astro/test/fixtures/astro-markdown-md-mode/astro.config.mjs b/packages/astro/test/fixtures/astro-markdown-md-mode/astro.config.mjs index 3fab631f34ad..908e3442fb0c 100644 --- a/packages/astro/test/fixtures/astro-markdown-md-mode/astro.config.mjs +++ b/packages/astro/test/fixtures/astro-markdown-md-mode/astro.config.mjs @@ -3,9 +3,6 @@ import svelte from "@astrojs/svelte"; // https://astro.build/config export default defineConfig({ - markdown: { - mode: 'md', - }, integrations: [svelte()], site: 'https://astro.build/', }); diff --git a/packages/astro/test/fixtures/astro-markdown/astro.config.mjs b/packages/astro/test/fixtures/astro-markdown/astro.config.mjs index be33a26cced0..baefed8cc2c6 100644 --- a/packages/astro/test/fixtures/astro-markdown/astro.config.mjs +++ b/packages/astro/test/fixtures/astro-markdown/astro.config.mjs @@ -6,4 +6,7 @@ import svelte from "@astrojs/svelte"; export default defineConfig({ integrations: [preact(), svelte()], site: 'https://astro.build/', + legacy: { + astroFlavoredMarkdown: true, + } }); diff --git a/packages/astro/test/fixtures/import-ts-with-js/astro.config.mjs b/packages/astro/test/fixtures/import-ts-with-js/astro.config.mjs new file mode 100644 index 000000000000..410c20408698 --- /dev/null +++ b/packages/astro/test/fixtures/import-ts-with-js/astro.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, + integrations: [] +}); diff --git a/packages/astro/test/fixtures/slots-preact/astro.config.mjs b/packages/astro/test/fixtures/slots-preact/astro.config.mjs index cd324a40fee3..01ce725cc261 100644 --- a/packages/astro/test/fixtures/slots-preact/astro.config.mjs +++ b/packages/astro/test/fixtures/slots-preact/astro.config.mjs @@ -1,7 +1,11 @@ import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; import preact from '@astrojs/preact'; // https://astro.build/config export default defineConfig({ - integrations: [preact()], -}); \ No newline at end of file + legacy: { + astroFlavoredMarkdown: true, + }, + integrations: [preact(), mdx()], +}); diff --git a/packages/astro/test/fixtures/slots-preact/package.json b/packages/astro/test/fixtures/slots-preact/package.json index 95f33ee50076..2400715788e1 100644 --- a/packages/astro/test/fixtures/slots-preact/package.json +++ b/packages/astro/test/fixtures/slots-preact/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/mdx": "workspace:*", "@astrojs/preact": "workspace:*", "astro": "workspace:*" } diff --git a/packages/astro/test/fixtures/slots-preact/src/pages/mdx.mdx b/packages/astro/test/fixtures/slots-preact/src/pages/mdx.mdx new file mode 100644 index 000000000000..6b7bcea55719 --- /dev/null +++ b/packages/astro/test/fixtures/slots-preact/src/pages/mdx.mdx @@ -0,0 +1,7 @@ +import Counter from '../components/Counter.jsx' + +# Slots: Preact + +

Hello world!

+

/ Named

+

/ Dash Case

diff --git a/packages/astro/test/fixtures/slots-react/astro.config.mjs b/packages/astro/test/fixtures/slots-react/astro.config.mjs index f03a45258926..20fa1428ec0e 100644 --- a/packages/astro/test/fixtures/slots-react/astro.config.mjs +++ b/packages/astro/test/fixtures/slots-react/astro.config.mjs @@ -1,7 +1,11 @@ import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; import react from '@astrojs/react'; // https://astro.build/config export default defineConfig({ - integrations: [react()], -}); \ No newline at end of file + legacy: { + astroFlavoredMarkdown: true, + }, + integrations: [react(), mdx()], +}); diff --git a/packages/astro/test/fixtures/slots-react/package.json b/packages/astro/test/fixtures/slots-react/package.json index 8159a5624f59..bea72fe3daa6 100644 --- a/packages/astro/test/fixtures/slots-react/package.json +++ b/packages/astro/test/fixtures/slots-react/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/mdx": "workspace:*", "@astrojs/react": "workspace:*", "astro": "workspace:*", "react": "^18.1.0", diff --git a/packages/astro/test/fixtures/slots-react/src/pages/mdx.mdx b/packages/astro/test/fixtures/slots-react/src/pages/mdx.mdx new file mode 100644 index 000000000000..f50196171d31 --- /dev/null +++ b/packages/astro/test/fixtures/slots-react/src/pages/mdx.mdx @@ -0,0 +1,7 @@ +import Counter from '../components/Counter.jsx' + +# Slots: React + +

Hello world!

+

/ Named

+

/ Dash Case

diff --git a/packages/astro/test/fixtures/slots-solid/astro.config.mjs b/packages/astro/test/fixtures/slots-solid/astro.config.mjs index 6bc082cce274..35d38c8f1566 100644 --- a/packages/astro/test/fixtures/slots-solid/astro.config.mjs +++ b/packages/astro/test/fixtures/slots-solid/astro.config.mjs @@ -1,7 +1,11 @@ import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; import solid from '@astrojs/solid-js'; // https://astro.build/config export default defineConfig({ - integrations: [solid()], -}); \ No newline at end of file + legacy: { + astroFlavoredMarkdown: true, + }, + integrations: [solid(), mdx()], +}); diff --git a/packages/astro/test/fixtures/slots-solid/package.json b/packages/astro/test/fixtures/slots-solid/package.json index e378bd772457..be9555acecbe 100644 --- a/packages/astro/test/fixtures/slots-solid/package.json +++ b/packages/astro/test/fixtures/slots-solid/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/mdx": "workspace:*", "@astrojs/solid-js": "workspace:*", "astro": "workspace:*" } diff --git a/packages/astro/test/fixtures/slots-solid/src/pages/mdx.mdx b/packages/astro/test/fixtures/slots-solid/src/pages/mdx.mdx new file mode 100644 index 000000000000..679f42ab9bc8 --- /dev/null +++ b/packages/astro/test/fixtures/slots-solid/src/pages/mdx.mdx @@ -0,0 +1,7 @@ +import Counter from '../components/Counter.jsx' + +# Slots: Solid + +

Hello world!

+

/ Named

+

/ Dash Case

diff --git a/packages/astro/test/fixtures/slots-svelte/astro.config.mjs b/packages/astro/test/fixtures/slots-svelte/astro.config.mjs index dbf6d6b8fa46..afd7dd326797 100644 --- a/packages/astro/test/fixtures/slots-svelte/astro.config.mjs +++ b/packages/astro/test/fixtures/slots-svelte/astro.config.mjs @@ -1,7 +1,11 @@ import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; import svelte from '@astrojs/svelte'; // https://astro.build/config export default defineConfig({ - integrations: [svelte()], -}); \ No newline at end of file + legacy: { + astroFlavoredMarkdown: true, + }, + integrations: [svelte(), mdx()], +}); diff --git a/packages/astro/test/fixtures/slots-svelte/package.json b/packages/astro/test/fixtures/slots-svelte/package.json index 53af8ea93dd2..95dbd239d268 100644 --- a/packages/astro/test/fixtures/slots-svelte/package.json +++ b/packages/astro/test/fixtures/slots-svelte/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/mdx": "workspace:*", "@astrojs/svelte": "workspace:*", "astro": "workspace:*" } diff --git a/packages/astro/test/fixtures/slots-svelte/src/pages/mdx.mdx b/packages/astro/test/fixtures/slots-svelte/src/pages/mdx.mdx new file mode 100644 index 000000000000..f93df5a10391 --- /dev/null +++ b/packages/astro/test/fixtures/slots-svelte/src/pages/mdx.mdx @@ -0,0 +1,7 @@ +import Counter from '../components/Counter.svelte' + +# Slots: Svelte + +

Hello world!

+

/ Named

+

/ Dash Case

diff --git a/packages/astro/test/fixtures/slots-vue/astro.config.mjs b/packages/astro/test/fixtures/slots-vue/astro.config.mjs index 8a3a38574b9f..1fbe9ba8e23b 100644 --- a/packages/astro/test/fixtures/slots-vue/astro.config.mjs +++ b/packages/astro/test/fixtures/slots-vue/astro.config.mjs @@ -1,7 +1,11 @@ import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; import vue from '@astrojs/vue'; // https://astro.build/config export default defineConfig({ - integrations: [vue()], -}); \ No newline at end of file + legacy: { + astroFlavoredMarkdown: true, + }, + integrations: [vue(), mdx()], +}); diff --git a/packages/astro/test/fixtures/slots-vue/package.json b/packages/astro/test/fixtures/slots-vue/package.json index f6d90b40d50b..4e2e7dd06940 100644 --- a/packages/astro/test/fixtures/slots-vue/package.json +++ b/packages/astro/test/fixtures/slots-vue/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { + "@astrojs/mdx": "workspace:*", "@astrojs/vue": "workspace:*", "astro": "workspace:*" } diff --git a/packages/astro/test/fixtures/slots-vue/src/pages/mdx.mdx b/packages/astro/test/fixtures/slots-vue/src/pages/mdx.mdx new file mode 100644 index 000000000000..65c8353110d9 --- /dev/null +++ b/packages/astro/test/fixtures/slots-vue/src/pages/mdx.mdx @@ -0,0 +1,7 @@ +import Counter from '../components/Counter.vue' + +# Slots: Vue + +

Hello world!

+

/ Named

+

/ Dash Case

diff --git a/packages/astro/test/fixtures/tailwindcss/astro.config.mjs b/packages/astro/test/fixtures/tailwindcss/astro.config.mjs index c3f6aad6f54f..34c98eaf8d12 100644 --- a/packages/astro/test/fixtures/tailwindcss/astro.config.mjs +++ b/packages/astro/test/fixtures/tailwindcss/astro.config.mjs @@ -3,10 +3,13 @@ import tailwind from '@astrojs/tailwind'; // https://astro.build/config export default defineConfig({ + legacy: { + astroFlavoredMarkdown: true, + }, integrations: [tailwind()], vite: { build: { assetsInlineLimit: 0, }, }, -}); \ No newline at end of file +}); diff --git a/packages/astro/test/slots-preact.test.js b/packages/astro/test/slots-preact.test.js index 4cfb7218ff4d..b7330a18242d 100644 --- a/packages/astro/test/slots-preact.test.js +++ b/packages/astro/test/slots-preact.test.js @@ -53,4 +53,24 @@ describe('Slots: Preact', () => { expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); }); }); + + describe('For MDX Pages', () => { + it('Renders default slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#content').text().trim()).to.equal('Hello world!'); + }); + + it('Renders named slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#named').text().trim()).to.equal('Fallback / Named'); + }); + + it('Converts dash-case slot to camelCase', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); + }); + }); }); diff --git a/packages/astro/test/slots-react.test.js b/packages/astro/test/slots-react.test.js index f3356dd58064..8e61d41ecd19 100644 --- a/packages/astro/test/slots-react.test.js +++ b/packages/astro/test/slots-react.test.js @@ -53,4 +53,24 @@ describe('Slots: React', () => { expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); }); }); + + describe('For MDX Pages', () => { + it('Renders default slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#content').text().trim()).to.equal('Hello world!'); + }); + + it('Renders named slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#named').text().trim()).to.equal('Fallback / Named'); + }); + + it('Converts dash-case slot to camelCase', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); + }); + }); }); diff --git a/packages/astro/test/slots-solid.test.js b/packages/astro/test/slots-solid.test.js index ecef1839e8ff..60e3231c9fa0 100644 --- a/packages/astro/test/slots-solid.test.js +++ b/packages/astro/test/slots-solid.test.js @@ -53,4 +53,24 @@ describe('Slots: Solid', () => { expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); }); }); + + describe('For MDX Pages', () => { + it('Renders default slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#content').text().trim()).to.equal('Hello world!'); + }); + + it('Renders named slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#named').text().trim()).to.equal('Fallback / Named'); + }); + + it('Converts dash-case slot to camelCase', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); + }); + }); }); diff --git a/packages/astro/test/slots-svelte.test.js b/packages/astro/test/slots-svelte.test.js index 9176775874ef..a96a397e3e5d 100644 --- a/packages/astro/test/slots-svelte.test.js +++ b/packages/astro/test/slots-svelte.test.js @@ -53,4 +53,24 @@ describe('Slots: Svelte', () => { expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); }); }); + + describe('For MDX Pages', () => { + it('Renders default slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#content').text().trim()).to.equal('Hello world!'); + }); + + it('Renders named slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#named').text().trim()).to.equal('Fallback / Named'); + }); + + it('Preserves dash-case slot', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); + }); + }); }); diff --git a/packages/astro/test/slots-vue.test.js b/packages/astro/test/slots-vue.test.js index 64ec656e3535..2999904b7555 100644 --- a/packages/astro/test/slots-vue.test.js +++ b/packages/astro/test/slots-vue.test.js @@ -53,4 +53,24 @@ describe('Slots: Vue', () => { expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); }); }); + + describe('For MDX Pages', () => { + it('Renders default slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#content').text().trim()).to.equal('Hello world!'); + }); + + it('Renders named slot', async () => { + const html = await fixture.readFile('/mdx/index.html'); + const $ = cheerio.load(html); + expect($('#named').text().trim()).to.equal('Fallback / Named'); + }); + + it('Converts dash-case slot to camelCase', async () => { + const html = await fixture.readFile('/markdown/index.html'); + const $ = cheerio.load(html); + expect($('#dash-case').text().trim()).to.equal('Fallback / Dash Case'); + }); + }); }); diff --git a/packages/integrations/image/components/Image.astro b/packages/integrations/image/components/Image.astro index 326c1bc6c21c..18e35d1a6858 100644 --- a/packages/integrations/image/components/Image.astro +++ b/packages/integrations/image/components/Image.astro @@ -1,8 +1,7 @@ --- // @ts-ignore -import loader from 'virtual:image-loader'; -import { getImage } from '../src/index.js'; -import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../src/types.js'; +import { getImage } from '../dist/index.js'; +import type { ImageAttributes, ImageMetadata, TransformOptions, OutputFormat } from '../dist/types'; export interface LocalImageProps extends Omit, Omit { src: ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -19,7 +18,7 @@ export type Props = LocalImageProps | RemoteImageProps; const { loading = "lazy", decoding = "async", ...props } = Astro.props as Props; -const attrs = await getImage(loader, props); +const attrs = await getImage(props); --- diff --git a/packages/integrations/image/components/Picture.astro b/packages/integrations/image/components/Picture.astro index bff6aad89491..badfc7f46aa5 100644 --- a/packages/integrations/image/components/Picture.astro +++ b/packages/integrations/image/components/Picture.astro @@ -1,8 +1,6 @@ --- -// @ts-ignore -import loader from 'virtual:image-loader'; -import { getPicture } from '../src/get-picture.js'; -import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../src/types.js'; +import { getPicture } from '../dist/index.js'; +import type { ImageAttributes, ImageMetadata, OutputFormat, PictureAttributes, TransformOptions } from '../dist/types'; export interface LocalImageProps extends Omit, Omit, Pick { src: ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -25,7 +23,7 @@ export type Props = LocalImageProps | RemoteImageProps; const { src, alt, sizes, widths, aspectRatio, formats = ['avif', 'webp'], loading = 'lazy', decoding = 'async', ...attrs } = Astro.props as Props; -const { image, sources } = await getPicture({ loader, src, widths, formats, aspectRatio }); +const { image, sources } = await getPicture({ src, widths, formats, aspectRatio }); --- diff --git a/packages/integrations/image/package.json b/packages/integrations/image/package.json index e4d1f26f994c..816f08141754 100644 --- a/packages/integrations/image/package.json +++ b/packages/integrations/image/package.json @@ -54,6 +54,7 @@ "@types/etag": "^1.8.1", "@types/sharp": "^0.30.4", "astro": "workspace:*", - "astro-scripts": "workspace:*" + "astro-scripts": "workspace:*", + "tiny-glob": "^0.2.9" } } diff --git a/packages/integrations/image/src/build/ssg.ts b/packages/integrations/image/src/build/ssg.ts new file mode 100644 index 000000000000..951f62331f11 --- /dev/null +++ b/packages/integrations/image/src/build/ssg.ts @@ -0,0 +1,71 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { OUTPUT_DIR } from '../constants.js'; +import type { SSRImageService, TransformOptions } from '../types.js'; +import { isRemoteImage, loadLocalImage, loadRemoteImage } from '../utils/images.js'; +import { ensureDir } from '../utils/paths.js'; + +export interface SSGBuildParams { + loader: SSRImageService; + staticImages: Map>; + srcDir: URL; + outDir: URL; +} + +export async function ssgBuild({ loader, staticImages, srcDir, outDir }: SSGBuildParams) { + const inputFiles = new Set(); + + // process transforms one original image file at a time + for await (const [src, transformsMap] of staticImages) { + let inputFile: string | undefined = undefined; + let inputBuffer: Buffer | undefined = undefined; + + if (isRemoteImage(src)) { + // try to load the remote image + inputBuffer = await loadRemoteImage(src); + } else { + const inputFileURL = new URL(`.${src}`, srcDir); + inputFile = fileURLToPath(inputFileURL); + inputBuffer = await loadLocalImage(inputFile); + + // track the local file used so the original can be copied over + inputFiles.add(inputFile); + } + + if (!inputBuffer) { + // eslint-disable-next-line no-console + console.warn(`"${src}" image could not be fetched`); + continue; + } + + const transforms = Array.from(transformsMap.entries()); + + // process each transformed versiono of the + for await (const [filename, transform] of transforms) { + let outputFile: string; + + if (isRemoteImage(src)) { + const outputFileURL = new URL(path.join('./', OUTPUT_DIR, path.basename(filename)), outDir); + outputFile = fileURLToPath(outputFileURL); + } else { + const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), outDir); + outputFile = fileURLToPath(outputFileURL); + } + + const { data } = await loader.transform(inputBuffer, transform); + + ensureDir(path.dirname(outputFile)); + + await fs.writeFile(outputFile, data); + } + } + + // copy all original local images to dist + for await (const original of inputFiles) { + const to = original.replace(fileURLToPath(srcDir), fileURLToPath(outDir)); + + await ensureDir(path.dirname(to)); + await fs.copyFile(original, to); + } +} diff --git a/packages/integrations/image/src/build/ssr.ts b/packages/integrations/image/src/build/ssr.ts new file mode 100644 index 000000000000..2585868b9c8a --- /dev/null +++ b/packages/integrations/image/src/build/ssr.ts @@ -0,0 +1,28 @@ +import fs from 'fs/promises'; +import path from 'path'; +import glob from 'tiny-glob'; +import { fileURLToPath } from 'url'; +import { ensureDir } from '../utils/paths.js'; + +async function globImages(dir: URL) { + const srcPath = fileURLToPath(dir); + return await glob(`${srcPath}/**/*.{heic,heif,avif,jpeg,jpg,png,tiff,webp,gif}`, { + absolute: true, + }); +} + +export interface SSRBuildParams { + srcDir: URL; + outDir: URL; +} + +export async function ssrBuild({ srcDir, outDir }: SSRBuildParams) { + const images = await globImages(srcDir); + + for await (const image of images) { + const to = image.replace(fileURLToPath(srcDir), fileURLToPath(outDir)); + + await ensureDir(path.dirname(to)); + await fs.copyFile(image, to); + } +} diff --git a/packages/integrations/image/src/endpoints/dev.ts b/packages/integrations/image/src/endpoints/dev.ts index 67b37b177470..dfa7f4900e92 100644 --- a/packages/integrations/image/src/endpoints/dev.ts +++ b/packages/integrations/image/src/endpoints/dev.ts @@ -1,10 +1,9 @@ import type { APIRoute } from 'astro'; import { lookup } from 'mrmime'; -import { loadImage } from '../utils.js'; +import loader from '../loaders/sharp.js'; +import { loadImage } from '../utils/images.js'; export const get: APIRoute = async ({ request }) => { - const loader = globalThis.astroImage.ssrLoader; - try { const url = new URL(request.url); const transform = loader.parseTransform(url.searchParams); diff --git a/packages/integrations/image/src/endpoints/prod.ts b/packages/integrations/image/src/endpoints/prod.ts index 921b54853478..8a15c2e888bd 100644 --- a/packages/integrations/image/src/endpoints/prod.ts +++ b/packages/integrations/image/src/endpoints/prod.ts @@ -1,9 +1,10 @@ import type { APIRoute } from 'astro'; import etag from 'etag'; import { lookup } from 'mrmime'; +import { fileURLToPath } from 'url'; // @ts-ignore import loader from 'virtual:image-loader'; -import { isRemoteImage, loadRemoteImage } from '../utils.js'; +import { isRemoteImage, loadLocalImage, loadRemoteImage } from '../utils/images.js'; export const get: APIRoute = async ({ request }) => { try { @@ -14,12 +15,14 @@ export const get: APIRoute = async ({ request }) => { return new Response('Bad Request', { status: 400 }); } - // TODO: Can we lean on fs to load local images in SSR prod builds? - const href = isRemoteImage(transform.src) - ? new URL(transform.src) - : new URL(transform.src, url.origin); + let inputBuffer: Buffer | undefined = undefined; - const inputBuffer = await loadRemoteImage(href.toString()); + if (isRemoteImage(transform.src)) { + inputBuffer = await loadRemoteImage(transform.src); + } else { + const pathname = fileURLToPath(new URL(`../client${transform.src}`, import.meta.url)); + inputBuffer = await loadLocalImage(pathname); + } if (!inputBuffer) { return new Response(`"${transform.src} not found`, { status: 404 }); diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index f857bdc70f5b..81ef8c6b9222 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -1,137 +1,5 @@ -import type { AstroConfig, AstroIntegration } from 'astro'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { OUTPUT_DIR, PKG_NAME, ROUTE_PATTERN } from './constants.js'; -import sharp from './loaders/sharp.js'; -import { IntegrationOptions, TransformOptions } from './types.js'; -import { - ensureDir, - isRemoteImage, - loadLocalImage, - loadRemoteImage, - propsToFilename, -} from './utils.js'; -import { createPlugin } from './vite-plugin-astro-image.js'; -export * from './get-image.js'; -export * from './get-picture.js'; +import integration from './integration.js'; +export * from './lib/get-image.js'; +export * from './lib/get-picture.js'; -const createIntegration = (options: IntegrationOptions = {}): AstroIntegration => { - const resolvedOptions = { - serviceEntryPoint: '@astrojs/image/sharp', - ...options, - }; - - // During SSG builds, this is used to track all transformed images required. - const staticImages = new Map(); - - let _config: AstroConfig; - - function getViteConfiguration() { - return { - plugins: [createPlugin(_config, resolvedOptions)], - optimizeDeps: { - include: ['image-size', 'sharp'], - }, - ssr: { - noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint], - }, - }; - } - - return { - name: PKG_NAME, - hooks: { - 'astro:config:setup': ({ command, config, injectRoute, updateConfig }) => { - _config = config; - - // Always treat `astro dev` as SSR mode, even without an adapter - const mode = command === 'dev' || config.adapter ? 'ssr' : 'ssg'; - - updateConfig({ vite: getViteConfiguration() }); - - // Used to cache all images rendered to HTML - // Added to globalThis to share the same map in Node and Vite - function addStaticImage(transform: TransformOptions) { - staticImages.set(propsToFilename(transform), transform); - } - - // TODO: Add support for custom, user-provided filename format functions - function filenameFormat(transform: TransformOptions, searchParams: URLSearchParams) { - if (mode === 'ssg') { - return isRemoteImage(transform.src) - ? path.join(OUTPUT_DIR, path.basename(propsToFilename(transform))) - : path.join( - OUTPUT_DIR, - path.dirname(transform.src), - path.basename(propsToFilename(transform)) - ); - } else { - return `${ROUTE_PATTERN}?${searchParams.toString()}`; - } - } - - // Initialize the integration's globalThis namespace - // This is needed to share scope between Node and Vite - globalThis.astroImage = { - loader: undefined, // initialized in first getImage() call - ssrLoader: sharp, - command, - addStaticImage, - filenameFormat, - }; - - if (mode === 'ssr') { - injectRoute({ - pattern: ROUTE_PATTERN, - entryPoint: - command === 'dev' ? '@astrojs/image/endpoints/dev' : '@astrojs/image/endpoints/prod', - }); - } - }, - 'astro:build:done': async ({ dir }) => { - for await (const [filename, transform] of staticImages) { - const loader = globalThis.astroImage.loader; - - if (!loader || !('transform' in loader)) { - // this should never be hit, how was a staticImage added without an SSR service? - return; - } - - let inputBuffer: Buffer | undefined = undefined; - let outputFile: string; - - if (isRemoteImage(transform.src)) { - // try to load the remote image - inputBuffer = await loadRemoteImage(transform.src); - - const outputFileURL = new URL( - path.join('./', OUTPUT_DIR, path.basename(filename)), - dir - ); - outputFile = fileURLToPath(outputFileURL); - } else { - const inputFileURL = new URL(`.${transform.src}`, _config.srcDir); - const inputFile = fileURLToPath(inputFileURL); - inputBuffer = await loadLocalImage(inputFile); - - const outputFileURL = new URL(path.join('./', OUTPUT_DIR, filename), dir); - outputFile = fileURLToPath(outputFileURL); - } - - if (!inputBuffer) { - // eslint-disable-next-line no-console - console.warn(`"${transform.src}" image could not be fetched`); - continue; - } - - const { data } = await loader.transform(inputBuffer, transform); - ensureDir(path.dirname(outputFile)); - await fs.writeFile(outputFile, data); - } - }, - }, - }; -}; - -export default createIntegration; +export default integration; diff --git a/packages/integrations/image/src/integration.ts b/packages/integrations/image/src/integration.ts new file mode 100644 index 000000000000..afbeb00a9651 --- /dev/null +++ b/packages/integrations/image/src/integration.ts @@ -0,0 +1,93 @@ +import type { AstroConfig, AstroIntegration } from 'astro'; +import { ssgBuild } from './build/ssg.js'; +import { ssrBuild } from './build/ssr.js'; +import { PKG_NAME, ROUTE_PATTERN } from './constants.js'; +import { IntegrationOptions, TransformOptions } from './types.js'; +import { filenameFormat, propsToFilename } from './utils/paths.js'; +import { createPlugin } from './vite-plugin-astro-image.js'; + +export default function integration(options: IntegrationOptions = {}): AstroIntegration { + const resolvedOptions = { + serviceEntryPoint: '@astrojs/image/sharp', + ...options, + }; + + // During SSG builds, this is used to track all transformed images required. + const staticImages = new Map>(); + + let _config: AstroConfig; + let mode: 'ssr' | 'ssg'; + + function getViteConfiguration() { + return { + plugins: [createPlugin(_config, resolvedOptions)], + optimizeDeps: { + include: ['image-size', 'sharp'], + }, + ssr: { + noExternal: ['@astrojs/image', resolvedOptions.serviceEntryPoint], + }, + }; + } + + return { + name: PKG_NAME, + hooks: { + 'astro:config:setup': ({ command, config, injectRoute, updateConfig }) => { + _config = config; + + // Always treat `astro dev` as SSR mode, even without an adapter + mode = command === 'dev' || config.adapter ? 'ssr' : 'ssg'; + + updateConfig({ vite: getViteConfiguration() }); + + if (mode === 'ssr') { + injectRoute({ + pattern: ROUTE_PATTERN, + entryPoint: + command === 'dev' ? '@astrojs/image/endpoints/dev' : '@astrojs/image/endpoints/prod', + }); + } + }, + 'astro:server:setup': async () => { + globalThis.astroImage = {}; + }, + 'astro:build:setup': () => { + // Used to cache all images rendered to HTML + // Added to globalThis to share the same map in Node and Vite + function addStaticImage(transform: TransformOptions) { + const srcTranforms = staticImages.has(transform.src) + ? staticImages.get(transform.src)! + : new Map(); + + srcTranforms.set(propsToFilename(transform), transform); + + staticImages.set(transform.src, srcTranforms); + } + + // Helpers for building static images should only be available for SSG + globalThis.astroImage = + mode === 'ssg' + ? { + addStaticImage, + filenameFormat, + } + : {}; + }, + 'astro:build:done': async ({ dir }) => { + if (mode === 'ssr') { + // for SSR builds, copy all image files from src to dist + // to make sure they are available for use in production + await ssrBuild({ srcDir: _config.srcDir, outDir: dir }); + } else { + // for SSG builds, build all requested image transforms to dist + const loader = globalThis?.astroImage?.loader; + + if (loader && 'transform' in loader && staticImages.size > 0) { + await ssgBuild({ loader, staticImages, srcDir: _config.srcDir, outDir: dir }); + } + } + }, + }, + }; +} diff --git a/packages/integrations/image/src/get-image.ts b/packages/integrations/image/src/lib/get-image.ts similarity index 81% rename from packages/integrations/image/src/get-image.ts rename to packages/integrations/image/src/lib/get-image.ts index 10de5c039e3b..e0f57e873948 100644 --- a/packages/integrations/image/src/get-image.ts +++ b/packages/integrations/image/src/lib/get-image.ts @@ -1,5 +1,6 @@ import slash from 'slash'; -import { ROUTE_PATTERN } from './constants.js'; +import { ROUTE_PATTERN } from '../constants.js'; +import sharp from '../loaders/sharp.js'; import { ImageAttributes, ImageMetadata, @@ -7,8 +8,8 @@ import { isSSRService, OutputFormat, TransformOptions, -} from './types.js'; -import { isRemoteImage, parseAspectRatio } from './utils.js'; +} from '../types.js'; +import { isRemoteImage, parseAspectRatio } from '../utils/images.js'; export interface GetImageTransform extends Omit { src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; @@ -97,24 +98,33 @@ async function resolveTransform(input: GetImageTransform): Promise` for the transformed image. * - * @param loader @type {ImageService} The image service used for transforming images. * @param transform @type {TransformOptions} The transformations requested for the optimized image. * @returns @type {ImageAttributes} The HTML attributes to be included on the built `` element. */ -export async function getImage( - loader: ImageService, - transform: GetImageTransform -): Promise { - globalThis.astroImage.loader = loader; +export async function getImage(transform: GetImageTransform): Promise { + if (!transform.src) { + throw new Error('[@astrojs/image] `src` is required'); + } + + let loader = globalThis.astroImage?.loader; + + if (!loader) { + // @ts-ignore + const { default: mod } = await import('virtual:image-loader'); + loader = mod as ImageService; + globalThis.astroImage = globalThis.astroImage || {}; + globalThis.astroImage.loader = loader; + } const resolved = await resolveTransform(transform); const attributes = await loader.getImageAttributes(resolved); - const isDev = globalThis.astroImage.command === 'dev'; + // @ts-ignore + const isDev = import.meta.env.DEV; const isLocalImage = !isRemoteImage(resolved.src); - const _loader = isDev && isLocalImage ? globalThis.astroImage.ssrLoader : loader; + const _loader = isDev && isLocalImage ? sharp : loader; if (!_loader) { throw new Error('@astrojs/image: loader not found!'); @@ -125,11 +135,11 @@ export async function getImage( const { searchParams } = _loader.serializeTransform(resolved); // cache all images rendered to HTML - if (globalThis?.astroImage) { + if (globalThis.astroImage?.addStaticImage) { globalThis.astroImage.addStaticImage(resolved); } - const src = globalThis?.astroImage + const src = globalThis.astroImage?.filenameFormat ? globalThis.astroImage.filenameFormat(resolved, searchParams) : `${ROUTE_PATTERN}?${searchParams.toString()}`; diff --git a/packages/integrations/image/src/get-picture.ts b/packages/integrations/image/src/lib/get-picture.ts similarity index 81% rename from packages/integrations/image/src/get-picture.ts rename to packages/integrations/image/src/lib/get-picture.ts index f8ca694ad83f..7b72736166fd 100644 --- a/packages/integrations/image/src/get-picture.ts +++ b/packages/integrations/image/src/lib/get-picture.ts @@ -1,17 +1,10 @@ import { lookup } from 'mrmime'; import { extname } from 'path'; +import { ImageAttributes, ImageMetadata, OutputFormat, TransformOptions } from '../types.js'; +import { parseAspectRatio } from '../utils/images.js'; import { getImage } from './get-image.js'; -import { - ImageAttributes, - ImageMetadata, - ImageService, - OutputFormat, - TransformOptions, -} from './types.js'; -import { parseAspectRatio } from './utils.js'; export interface GetPictureParams { - loader: ImageService; src: string | ImageMetadata | Promise<{ default: ImageMetadata }>; widths: number[]; formats: OutputFormat[]; @@ -46,7 +39,15 @@ async function resolveFormats({ src, formats }: GetPictureParams) { } export async function getPicture(params: GetPictureParams): Promise { - const { loader, src, widths, formats } = params; + const { src, widths } = params; + + if (!src) { + throw new Error('[@astrojs/image] `src` is required'); + } + + if (!widths || !Array.isArray(widths)) { + throw new Error('[@astrojs/image] at least one `width` is required'); + } const aspectRatio = await resolveAspectRatio(params); @@ -57,7 +58,7 @@ export async function getPicture(params: GetPictureParams): Promise { - const img = await getImage(loader, { + const img = await getImage({ src, format, width, @@ -76,7 +77,7 @@ export async function getPicture(params: GetPictureParams): Promise void; - filenameFormat: (transform: TransformOptions, searchParams: URLSearchParams) => string; + addStaticImage?: (transform: TransformOptions) => void; + filenameFormat?: (transform: TransformOptions, searchParams: URLSearchParams) => string; } declare global { // eslint-disable-next-line no-var - var astroImage: ImageIntegration; + var astroImage: ImageIntegration | undefined; } export type InputFormat = diff --git a/packages/integrations/image/src/utils.ts b/packages/integrations/image/src/utils/images.ts similarity index 57% rename from packages/integrations/image/src/utils.ts rename to packages/integrations/image/src/utils/images.ts index 80dff1b6ea4e..55a45d1ce94d 100644 --- a/packages/integrations/image/src/utils.ts +++ b/packages/integrations/image/src/utils/images.ts @@ -1,7 +1,5 @@ -import fs from 'fs'; -import path from 'path'; -import { shorthash } from './shorthash.js'; -import type { OutputFormat, TransformOptions } from './types'; +import fs from 'fs/promises'; +import type { OutputFormat, TransformOptions } from '../types.js'; export function isOutputFormat(value: string): value is OutputFormat { return ['avif', 'jpeg', 'png', 'webp'].includes(value); @@ -11,17 +9,13 @@ export function isAspectRatioString(value: string): value is `${number}:${number return /^\d*:\d*$/.test(value); } -export function ensureDir(dir: string) { - fs.mkdirSync(dir, { recursive: true }); -} - export function isRemoteImage(src: string) { return /^http(s?):\/\//.test(src); } export async function loadLocalImage(src: string) { try { - return await fs.promises.readFile(src); + return await fs.readFile(src); } catch { return undefined; } @@ -45,26 +39,6 @@ export async function loadImage(src: string) { return isRemoteImage(src) ? await loadRemoteImage(src) : await loadLocalImage(src); } -export function propsToFilename({ src, width, height, format }: TransformOptions) { - const ext = path.extname(src); - let filename = src.replace(ext, ''); - - // for remote images, add a hash of the full URL to dedupe images with the same filename - if (isRemoteImage(src)) { - filename += `-${shorthash(src)}`; - } - - if (width && height) { - return `${filename}_${width}x${height}.${format}`; - } else if (width) { - return `${filename}_${width}w.${format}`; - } else if (height) { - return `${filename}_${height}h.${format}`; - } - - return format ? src.replace(ext, format) : src; -} - export function parseAspectRatio(aspectRatio: TransformOptions['aspectRatio']) { if (!aspectRatio) { return undefined; diff --git a/packages/integrations/image/src/metadata.ts b/packages/integrations/image/src/utils/metadata.ts similarity index 89% rename from packages/integrations/image/src/metadata.ts rename to packages/integrations/image/src/utils/metadata.ts index 823862ea7938..38859b817463 100644 --- a/packages/integrations/image/src/metadata.ts +++ b/packages/integrations/image/src/utils/metadata.ts @@ -1,6 +1,6 @@ import fs from 'fs/promises'; import sizeOf from 'image-size'; -import { ImageMetadata, InputFormat } from './types'; +import { ImageMetadata, InputFormat } from '../types.js'; export async function metadata(src: string): Promise { const file = await fs.readFile(src); diff --git a/packages/integrations/image/src/utils/paths.ts b/packages/integrations/image/src/utils/paths.ts new file mode 100644 index 000000000000..1ba299526e99 --- /dev/null +++ b/packages/integrations/image/src/utils/paths.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import path from 'path'; +import { OUTPUT_DIR } from '../constants.js'; +import type { TransformOptions } from '../types.js'; +import { isRemoteImage } from './images.js'; +import { shorthash } from './shorthash.js'; + +export function ensureDir(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +export function propsToFilename({ src, width, height, format }: TransformOptions) { + const ext = path.extname(src); + let filename = src.replace(ext, ''); + + // for remote images, add a hash of the full URL to dedupe images with the same filename + if (isRemoteImage(src)) { + filename += `-${shorthash(src)}`; + } + + if (width && height) { + return `${filename}_${width}x${height}.${format}`; + } else if (width) { + return `${filename}_${width}w.${format}`; + } else if (height) { + return `${filename}_${height}h.${format}`; + } + + return format ? src.replace(ext, format) : src; +} + +export function filenameFormat(transform: TransformOptions) { + return isRemoteImage(transform.src) + ? path.join(OUTPUT_DIR, path.basename(propsToFilename(transform))) + : path.join(OUTPUT_DIR, path.dirname(transform.src), path.basename(propsToFilename(transform))); +} diff --git a/packages/integrations/image/src/shorthash.ts b/packages/integrations/image/src/utils/shorthash.ts similarity index 100% rename from packages/integrations/image/src/shorthash.ts rename to packages/integrations/image/src/utils/shorthash.ts diff --git a/packages/integrations/image/src/vite-plugin-astro-image.ts b/packages/integrations/image/src/vite-plugin-astro-image.ts index 2dfda8fa5c79..7a494e98900a 100644 --- a/packages/integrations/image/src/vite-plugin-astro-image.ts +++ b/packages/integrations/image/src/vite-plugin-astro-image.ts @@ -1,11 +1,10 @@ import type { AstroConfig } from 'astro'; -import fs from 'fs/promises'; import type { PluginContext } from 'rollup'; import slash from 'slash'; import { pathToFileURL } from 'url'; import type { Plugin, ResolvedConfig } from 'vite'; -import { metadata } from './metadata.js'; -import type { IntegrationOptions } from './types'; +import type { IntegrationOptions } from './types.js'; +import { metadata } from './utils/metadata.js'; export function createPlugin(config: AstroConfig, options: Required): Plugin { const filter = (id: string) => @@ -60,14 +59,6 @@ export function createPlugin(config: AstroConfig, options: Required { + if (err) { + res.writeHead(500); + res.end(err.stack); + return; + } + + let local = new URL('.' + req.url, clientRoot); + try { + const data = await fs.promises.readFile(local); + res.writeHead(200, { + 'Content-Type': mime.getType(req.url), + }); + res.end(data); + } catch { + res.writeHead(404); + res.end(); + } + }); +} + +const server = createServer((req, res) => { + handle(req, res).catch((err) => { + console.error(err); + res.writeHead(500, { + 'Content-Type': 'text/plain', + }); + res.end(err.toString()); + }); +}); + +server.listen(8085); +console.log('Serving at http://localhost:8085'); + +// Silence weird