diff --git a/.changeset/breezy-cats-grab.md b/.changeset/breezy-cats-grab.md new file mode 100644 index 000000000..bc8228221 --- /dev/null +++ b/.changeset/breezy-cats-grab.md @@ -0,0 +1,15 @@ +--- +'astro': patch +'@astrojs/markdoc': patch +'@astrojs/mdx': patch +--- + +Introduce the (experimental) `@astrojs/markdoc` integration. This unlocks Markdoc inside your Content Collections, bringing support for Astro and UI components in your content. This also improves Astro core internals to make Content Collections extensible to more file types in the future. + +You can install this integration using the `astro add` command: + +``` +astro add markdoc +``` + +[Read the `@astrojs/markdoc` documentation](https://docs.astro.build/en/guides/integrations-guide/markdoc/) for usage instructions, and browse the [new `with-markdoc` starter](https://astro.new/with-markdoc) to try for yourself. diff --git a/examples/with-markdoc/.gitignore b/examples/with-markdoc/.gitignore new file mode 100644 index 000000000..6240da8b1 --- /dev/null +++ b/examples/with-markdoc/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/examples/with-markdoc/.vscode/extensions.json b/examples/with-markdoc/.vscode/extensions.json new file mode 100644 index 000000000..22a15055d --- /dev/null +++ b/examples/with-markdoc/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/examples/with-markdoc/.vscode/launch.json b/examples/with-markdoc/.vscode/launch.json new file mode 100644 index 000000000..d64220976 --- /dev/null +++ b/examples/with-markdoc/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/examples/with-markdoc/README.md b/examples/with-markdoc/README.md new file mode 100644 index 000000000..62f7cbfc8 --- /dev/null +++ b/examples/with-markdoc/README.md @@ -0,0 +1,59 @@ +# Astro Example: Markdoc (experimental) + +This starter showcases the experimental Markdoc integration. + +``` +npm create astro@latest -- --template with-markdoc +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-markdoc) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-markdoc) + +> πŸ§‘β€πŸš€ **Seasoned astronaut?** Delete this file. Have fun! + +## πŸš€ Project Structure + +Inside of your Astro project, you'll see the following folders and files: + +``` +/ +β”œβ”€β”€ public/ +β”œβ”€β”€ src/ +β”‚ └── content/ + └── docs/ +β”‚ └── intro.mdoc +| └── config.ts +β”‚ └── components/ +| β”œβ”€β”€ Aside.astro +β”‚ └── DocsContent.astro +β”‚ └── layouts/ +β”‚ └── Layout.astro +β”‚ └── pages/ +β”‚ └── index.astro +| └── env.d.ts +β”œβ”€β”€ astro.config.mjs +β”œβ”€β”€ README.md +β”œβ”€β”€ package.json +└── tsconfig.json +``` + +Markdoc (`.mdoc`) files can be used in content collections to author your Markdown content alongside Astro and server-rendered UI framework components (React, Vue, Svelte, and more). See `src/content/docs/` for an example file. + +You can also apply Astro components and server-rendered UI components (React, Vue, Svelte, etc) to your Markdoc files. See `src/content/DocsContent.astro` for an example. + +## 🧞 Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +| :--------------------- | :----------------------------------------------- | +| `npm install` | Installs dependencies | +| `npm run dev` | Starts local dev server at `localhost:3000` | +| `npm run build` | Build your production site to `./dist/` | +| `npm run preview` | Preview your build locally, before deploying | +| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | +| `npm run astro --help` | Get help using the Astro CLI | + +## πŸ‘€ Want to learn more? + +Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/examples/with-markdoc/astro.config.mjs b/examples/with-markdoc/astro.config.mjs new file mode 100644 index 000000000..d88ed2098 --- /dev/null +++ b/examples/with-markdoc/astro.config.mjs @@ -0,0 +1,19 @@ +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + markdoc({ + tags: { + aside: { + render: 'Aside', + attributes: { + type: { type: String }, + title: { type: String }, + }, + }, + }, + }), + ], +}); diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json new file mode 100644 index 000000000..694ad40ad --- /dev/null +++ b/examples/with-markdoc/package.json @@ -0,0 +1,17 @@ +{ + "name": "@example/with-markdoc", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/markdoc": "^0.0.0", + "astro": "^2.0.6" + } +} diff --git a/examples/with-markdoc/public/favicon.svg b/examples/with-markdoc/public/favicon.svg new file mode 100644 index 000000000..0f3906297 --- /dev/null +++ b/examples/with-markdoc/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/examples/with-markdoc/sandbox.config.json b/examples/with-markdoc/sandbox.config.json new file mode 100644 index 000000000..9178af77d --- /dev/null +++ b/examples/with-markdoc/sandbox.config.json @@ -0,0 +1,11 @@ +{ + "infiniteLoopProtection": true, + "hardReloadOnChange": false, + "view": "browser", + "template": "node", + "container": { + "port": 3000, + "startScript": "start", + "node": "14" + } +} diff --git a/examples/with-markdoc/src/components/Aside.astro b/examples/with-markdoc/src/components/Aside.astro new file mode 100644 index 000000000..5d92a0993 --- /dev/null +++ b/examples/with-markdoc/src/components/Aside.astro @@ -0,0 +1,116 @@ +--- +// Inspired by the `Aside` component from docs.astro.build +// https://github.com/withastro/docs/blob/main/src/components/Aside.astro + +export interface Props { + type?: 'note' | 'tip' | 'caution' | 'danger'; + title?: string; +} + +const labelByType = { + note: 'Note', + tip: 'Tip', + caution: 'Caution', + danger: 'Danger', +}; +const { type = 'note' } = Astro.props as Props; +const title = Astro.props.title ?? labelByType[type] ?? ''; + +// SVG icon paths based on GitHub Octicons +const icons: Record, { viewBox: string; d: string }> = { + note: { + viewBox: '0 0 18 18', + d: 'M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm1.75-.25a.25.25 0 00-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H1.75zM3.5 6.25a.75.75 0 01.75-.75h7a.75.75 0 010 1.5h-7a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5h4a.75.75 0 000-1.5h-4z', + }, + tip: { + viewBox: '0 0 18 18', + d: 'M14 0a8.8 8.8 0 0 0-6 2.6l-.5.4-.9 1H3.3a1.8 1.8 0 0 0-1.5.8L.1 7.6a.8.8 0 0 0 .4 1.1l3.1 1 .2.1 2.4 2.4.1.2 1 3a.8.8 0 0 0 1 .5l2.9-1.7a1.8 1.8 0 0 0 .8-1.5V9.5l1-1 .4-.4A8.8 8.8 0 0 0 16 2v-.1A1.8 1.8 0 0 0 14.2 0h-.1zm-3.5 10.6-.3.2L8 12.3l.5 1.8 2-1.2a.3.3 0 0 0 .1-.2v-2zM3.7 8.1l1.5-2.3.2-.3h-2a.3.3 0 0 0-.3.1l-1.2 2 1.8.5zm5.2-4.5a7.3 7.3 0 0 1 5.2-2.1h.1a.3.3 0 0 1 .3.3v.1a7.3 7.3 0 0 1-2.1 5.2l-.5.4a15.2 15.2 0 0 1-2.5 2L7.1 11 5 9l1.5-2.3a15.3 15.3 0 0 1 2-2.5l.4-.5zM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-8.4 9.6a1.5 1.5 0 1 0-2.2-2.2 7 7 0 0 0-1.1 3 .2.2 0 0 0 .3.3c.6 0 2.2-.4 3-1.1z', + }, + caution: { + viewBox: '-1 1 18 18', + d: 'M8.9 1.5C8.7 1.2 8.4 1 8 1s-.7.2-.9.5l-7 12a1 1 0 0 0 0 1c.2.3.6.5 1 .5H15c.4 0 .7-.2.9-.5a1 1 0 0 0 0-1l-7-12zM9 13H7v-2h2v2zm0-3H7V6h2v4z', + }, + danger: { + viewBox: '0 1 14 17', + d: 'M5 .3c.9 2.2.5 3.4-.5 4.3C3.5 5.6 2 6.5 1 8c-1.5 2-1.7 6.5 3.5 7.7-2.2-1.2-2.6-4.5-.3-6.6-.6 2 .6 3.3 2 2.8 1.4-.4 2.3.6 2.2 1.7 0 .8-.3 1.4-1 1.8A5.6 5.6 0 0 0 12 10c0-2.9-2.5-3.3-1.3-5.7-1.5.2-2 1.2-1.8 2.8 0 1-1 1.8-2 1.3-.6-.4-.6-1.2 0-1.8C8.2 5.3 8.7 2.5 5 .3z', + }, +}; +const { viewBox, d } = icons[type]; +--- + + + + diff --git a/examples/with-markdoc/src/components/DocsContent.astro b/examples/with-markdoc/src/components/DocsContent.astro new file mode 100644 index 000000000..162c1fc6d --- /dev/null +++ b/examples/with-markdoc/src/components/DocsContent.astro @@ -0,0 +1,32 @@ +--- +import Aside from './Aside.astro'; +import type { CollectionEntry } from 'astro:content'; + +type Props = { + entry: CollectionEntry<'docs'>; +}; + +const { entry } = Astro.props; +const { Content } = await entry.render(); +--- + + + + diff --git a/examples/with-markdoc/src/content/config.ts b/examples/with-markdoc/src/content/config.ts new file mode 100644 index 000000000..2eccab0a3 --- /dev/null +++ b/examples/with-markdoc/src/content/config.ts @@ -0,0 +1,9 @@ +import { defineCollection, z } from 'astro:content'; + +const docs = defineCollection({ + schema: z.object({ + title: z.string(), + }), +}); + +export const collections = { docs }; diff --git a/examples/with-markdoc/src/content/docs/intro.mdoc b/examples/with-markdoc/src/content/docs/intro.mdoc new file mode 100644 index 000000000..da987d9ec --- /dev/null +++ b/examples/with-markdoc/src/content/docs/intro.mdoc @@ -0,0 +1,39 @@ +--- +title: Welcome to Markdoc πŸ‘‹ +--- + +This simple starter showcases Markdoc with Content Collections. All Markdoc features are supported, including this nifty built-in `{% table %}` tag: + +{% table %} +* Feature +* Supported +--- +* `.mdoc` in Content Collections +* βœ… +--- +* Markdoc transform configuration +* βœ… +--- +* Astro components +* βœ… +{% /table %} + +{% aside title="Code Challenge" type="tip" %} + +Reveal the secret message below by adding `revealSecret: true` to your list of Markdoc variables. + +_Hint: Try [adding a `variables` object](https://markdoc.dev/docs/variables#global-variables) to your Markdoc config. Check the `astro.config.mjs`._ + +{% if $revealSecret %} + +Maybe the real secret was the Rick Rolls we shared along the way. + +![Rick Astley dancing](https://media.tenor.com/x8v1oNUOmg4AAAAM/rickroll-roll.gif) + +{% /if %} + +{% /aside %} + +Check out [the `@astrojs/markdoc` integration][astro-markdoc] for complete documentation and usage examples. + +[astro-markdoc]: https://docs.astro.build/en/guides/integrations-guide/markdoc/ diff --git a/examples/with-markdoc/src/env.d.ts b/examples/with-markdoc/src/env.d.ts new file mode 100644 index 000000000..acef35f17 --- /dev/null +++ b/examples/with-markdoc/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/with-markdoc/src/layouts/Layout.astro b/examples/with-markdoc/src/layouts/Layout.astro new file mode 100644 index 000000000..fa47dafce --- /dev/null +++ b/examples/with-markdoc/src/layouts/Layout.astro @@ -0,0 +1,35 @@ +--- +export interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + {title} + + + + + + diff --git a/examples/with-markdoc/src/pages/index.astro b/examples/with-markdoc/src/pages/index.astro new file mode 100644 index 000000000..01412cce1 --- /dev/null +++ b/examples/with-markdoc/src/pages/index.astro @@ -0,0 +1,18 @@ +--- +import { getEntryBySlug } from 'astro:content'; +import DocsContent from '../components/DocsContent.astro'; +import Layout from '../layouts/Layout.astro'; + +const intro = await getEntryBySlug('docs', 'intro'); +--- + + +
+

{intro.data.title}

+ + + + + +
+
diff --git a/examples/with-markdoc/tsconfig.json b/examples/with-markdoc/tsconfig.json new file mode 100644 index 000000000..e51e06270 --- /dev/null +++ b/examples/with-markdoc/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "strictNullChecks": true + } +} diff --git a/packages/astro/performance/.gitignore b/packages/astro/performance/.gitignore new file mode 100644 index 000000000..f25aeb910 --- /dev/null +++ b/packages/astro/performance/.gitignore @@ -0,0 +1,2 @@ +.astro/ +fixtures/**/src/content/generated/ diff --git a/packages/astro/performance/content-benchmark.mjs b/packages/astro/performance/content-benchmark.mjs new file mode 100644 index 000000000..126d99c73 --- /dev/null +++ b/packages/astro/performance/content-benchmark.mjs @@ -0,0 +1,122 @@ +/* eslint-disable no-console */ + +import { fileURLToPath } from 'url'; +import { loadFixture } from '../test/test-utils.js'; +import { generatePosts } from './scripts/generate-posts.mjs'; +import yargs from 'yargs-parser'; +import { cyan, bold, dim } from 'kleur/colors'; + +// Skip nonessential remark / rehype plugins for a fair comparison. +// This includes heading ID generation, syntax highlighting, GFM, and Smartypants. +process.env.ASTRO_PERFORMANCE_BENCHMARK = true; + +const extByFixture = { + md: '.md', + mdx: '.mdx', + mdoc: '.mdoc', +}; + +async function benchmark({ fixtures, templates, numPosts }) { + for (const fixture of fixtures) { + const root = new URL(`./fixtures/${fixture}/`, import.meta.url); + await generatePosts({ + postsDir: fileURLToPath(new URL('./src/content/generated/', root)), + numPosts, + ext: extByFixture[fixture], + template: templates[fixture], + }); + console.log(`[${fixture}] Generated posts`); + + const { build } = await loadFixture({ + root, + }); + const now = performance.now(); + console.log(`[${fixture}] Building...`); + await build(); + console.log(cyan(`[${fixture}] Built in ${bold(getTimeStat(now, performance.now()))}.`)); + } +} + +// Test the build performance for content collections across multiple file types (md, mdx, mdoc) +(async function benchmarkAll() { + try { + const flags = yargs(process.argv.slice(2)); + const test = Array.isArray(flags.test) + ? flags.test + : typeof flags.test === 'string' + ? [flags.test] + : ['simple', 'with-astro-components', 'with-react-components']; + + const formats = Array.isArray(flags.format) + ? flags.format + : typeof flags.format === 'string' + ? [flags.format] + : ['md', 'mdx', 'mdoc']; + + const numPosts = flags.numPosts || 1000; + + if (test.includes('simple')) { + const fixtures = formats; + console.log( + `\n${bold('Simple')} ${dim(`${numPosts} posts (${formatsToString(fixtures)})`)}` + ); + process.env.ASTRO_PERFORMANCE_TEST_NAME = 'simple'; + await benchmark({ + fixtures, + templates: { + md: 'simple.md', + mdx: 'simple.md', + mdoc: 'simple.md', + }, + numPosts, + }); + } + + if (test.includes('with-astro-components')) { + const fixtures = formats.filter((format) => format !== 'md'); + console.log( + `\n${bold('With Astro components')} ${dim( + `${numPosts} posts (${formatsToString(fixtures)})` + )}` + ); + process.env.ASTRO_PERFORMANCE_TEST_NAME = 'with-astro-components'; + await benchmark({ + fixtures, + templates: { + mdx: 'with-astro-components.mdx', + mdoc: 'with-astro-components.mdoc', + }, + numPosts, + }); + } + + if (test.includes('with-react-components')) { + const fixtures = formats.filter((format) => format !== 'md'); + console.log( + `\n${bold('With React components')} ${dim( + `${numPosts} posts (${formatsToString(fixtures)})` + )}` + ); + process.env.ASTRO_PERFORMANCE_TEST_NAME = 'with-react-components'; + await benchmark({ + fixtures, + templates: { + mdx: 'with-react-components.mdx', + mdoc: 'with-react-components.mdoc', + }, + numPosts, + }); + } + } finally { + process.env.ASTRO_PERFORMANCE_BENCHMARK = false; + } +})(); + +function getTimeStat(timeStart, timeEnd) { + const buildTime = timeEnd - timeStart; + return buildTime < 750 ? `${Math.round(buildTime)}ms` : `${(buildTime / 1000).toFixed(2)}s`; +} + +function formatsToString(formats) { + return formats.join(', '); +} diff --git a/packages/astro/performance/fixtures/md/astro.config.mjs b/packages/astro/performance/fixtures/md/astro.config.mjs new file mode 100644 index 000000000..0af86d603 --- /dev/null +++ b/packages/astro/performance/fixtures/md/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +import react from "@astrojs/react"; + +// https://astro.build/config +export default defineConfig({ + integrations: [react()] +}); diff --git a/packages/astro/performance/fixtures/md/package.json b/packages/astro/performance/fixtures/md/package.json new file mode 100644 index 000000000..45bb2cd6b --- /dev/null +++ b/packages/astro/performance/fixtures/md/package.json @@ -0,0 +1,25 @@ +{ + "name": "@performance/md", + "version": "1.0.0", + "description": "", + "main": "index.js", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "keywords": [], + "author": "", + "license": "unlicensed", + "dependencies": { + "@astrojs/react": "^2.0.2", + "@performance/utils": "^0.0.0", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "astro": "^2.0.12", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/packages/astro/performance/fixtures/md/src/ContentRenderer.astro b/packages/astro/performance/fixtures/md/src/ContentRenderer.astro new file mode 100644 index 000000000..307541fcf --- /dev/null +++ b/packages/astro/performance/fixtures/md/src/ContentRenderer.astro @@ -0,0 +1,12 @@ +--- +import type { CollectionEntry } from 'astro:content'; + +type Props = { + entry: CollectionEntry<'generated'>; +} + +const { entry } = Astro.props as Props; +const { Content } = await entry.render(); +--- + + diff --git a/packages/astro/performance/fixtures/md/src/env.d.ts b/packages/astro/performance/fixtures/md/src/env.d.ts new file mode 100644 index 000000000..4b38f4e5c --- /dev/null +++ b/packages/astro/performance/fixtures/md/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// \ No newline at end of file diff --git a/packages/astro/performance/fixtures/md/src/pages/index.astro b/packages/astro/performance/fixtures/md/src/pages/index.astro new file mode 100644 index 000000000..efb43284e --- /dev/null +++ b/packages/astro/performance/fixtures/md/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import { getCollection } from 'astro:content'; +import { RenderContent } from '@performance/utils'; +import ContentRenderer from '../ContentRenderer.astro'; + +const posts = await getCollection('generated'); +--- + + diff --git a/packages/astro/performance/fixtures/md/tsconfig.json b/packages/astro/performance/fixtures/md/tsconfig.json new file mode 100644 index 000000000..7fb90fafc --- /dev/null +++ b/packages/astro/performance/fixtures/md/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file diff --git a/packages/astro/performance/fixtures/mdoc/astro.config.mjs b/packages/astro/performance/fixtures/mdoc/astro.config.mjs new file mode 100644 index 000000000..aae6ad223 --- /dev/null +++ b/packages/astro/performance/fixtures/mdoc/astro.config.mjs @@ -0,0 +1,39 @@ +import { defineConfig } from 'astro/config'; +import markdoc from "@astrojs/markdoc"; +import react from "@astrojs/react"; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc({ + nodes: process.env.ASTRO_PERFORMANCE_TEST_NAME === 'with-astro-components' ? { + heading: { + render: 'Heading', + attributes: { + level: { type: Number }, + }, + } + } : {}, + tags: process.env.ASTRO_PERFORMANCE_TEST_NAME === 'with-astro-components' ? { + aside: { + render: 'Aside', + attributes: { + type: { type: String }, + title: { type: String }, + }, + } + } : process.env.ASTRO_PERFORMANCE_TEST_NAME === 'with-react-components' ? { + 'like-button': { + render: 'LikeButton', + attributes: { + liked: { type: Boolean }, + }, + }, + 'hydrated-like-button': { + render: 'HydratedLikeButton', + attributes: { + liked: { type: Boolean }, + }, + }, + } : {}, + }), react()], +}); diff --git a/packages/astro/performance/fixtures/mdoc/package.json b/packages/astro/performance/fixtures/mdoc/package.json new file mode 100644 index 000000000..b49f4194a --- /dev/null +++ b/packages/astro/performance/fixtures/mdoc/package.json @@ -0,0 +1,26 @@ +{ + "name": "@performance/mdoc", + "version": "1.0.0", + "description": "", + "main": "index.js", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "keywords": [], + "author": "", + "license": "unlicensed", + "dependencies": { + "@astrojs/markdoc": "^0.0.0", + "@astrojs/react": "^2.0.2", + "@performance/utils": "^0.0.0", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "astro": "^2.0.12", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/packages/astro/performance/fixtures/mdoc/src/ContentRenderer.astro b/packages/astro/performance/fixtures/mdoc/src/ContentRenderer.astro new file mode 100644 index 000000000..3008f6119 --- /dev/null +++ b/packages/astro/performance/fixtures/mdoc/src/ContentRenderer.astro @@ -0,0 +1,18 @@ +--- +import { Heading, Aside, LikeButton, HydratedLikeButton } from '@performance/utils'; +import type { CollectionEntry } from 'astro:content'; + +type Props = { + entry: CollectionEntry<'generated'>; +} + +const { entry } = Astro.props as Props; +const { Content } = await entry.render(); +--- + +{entry.data.type === 'with-astro-components' + ? + : entry.data.type === 'with-react-components' + ? + : +} diff --git a/packages/astro/performance/fixtures/mdoc/src/env.d.ts b/packages/astro/performance/fixtures/mdoc/src/env.d.ts new file mode 100644 index 000000000..acef35f17 --- /dev/null +++ b/packages/astro/performance/fixtures/mdoc/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/astro/performance/fixtures/mdoc/src/pages/index.astro b/packages/astro/performance/fixtures/mdoc/src/pages/index.astro new file mode 100644 index 000000000..efb43284e --- /dev/null +++ b/packages/astro/performance/fixtures/mdoc/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import { getCollection } from 'astro:content'; +import { RenderContent } from '@performance/utils'; +import ContentRenderer from '../ContentRenderer.astro'; + +const posts = await getCollection('generated'); +--- + + diff --git a/packages/astro/performance/fixtures/mdoc/tsconfig.json b/packages/astro/performance/fixtures/mdoc/tsconfig.json new file mode 100644 index 000000000..7fb90fafc --- /dev/null +++ b/packages/astro/performance/fixtures/mdoc/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file diff --git a/packages/astro/performance/fixtures/mdx/astro.config.mjs b/packages/astro/performance/fixtures/mdx/astro.config.mjs new file mode 100644 index 000000000..a063da817 --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; +import mdx from "@astrojs/mdx"; +import react from "@astrojs/react"; + +// https://astro.build/config +export default defineConfig({ + integrations: [mdx(), react()] +}); diff --git a/packages/astro/performance/fixtures/mdx/package.json b/packages/astro/performance/fixtures/mdx/package.json new file mode 100644 index 000000000..9208329a1 --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/package.json @@ -0,0 +1,26 @@ +{ + "name": "@performance/mdx", + "version": "1.0.0", + "description": "", + "main": "index.js", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "keywords": [], + "author": "", + "license": "unlicensed", + "dependencies": { + "@astrojs/mdx": "^0.17.2", + "@astrojs/react": "^2.0.2", + "@performance/utils": "^0.0.0", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "astro": "^2.0.12", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } +} diff --git a/packages/astro/performance/fixtures/mdx/src/ContentRenderer.astro b/packages/astro/performance/fixtures/mdx/src/ContentRenderer.astro new file mode 100644 index 000000000..42c2da57a --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/src/ContentRenderer.astro @@ -0,0 +1,16 @@ +--- +import Title from './Title.astro'; +import type { CollectionEntry } from 'astro:content'; + +type Props = { + entry: CollectionEntry<'generated'>; +} + +const { entry } = Astro.props as Props; +const { Content } = await entry.render(); +--- + +{entry.data.type === 'with-astro-components' + ? + : +} diff --git a/packages/astro/performance/fixtures/mdx/src/Title.astro b/packages/astro/performance/fixtures/mdx/src/Title.astro new file mode 100644 index 000000000..ede38c1aa --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/src/Title.astro @@ -0,0 +1,5 @@ +--- +import { Heading } from '@performance/utils'; +--- + + diff --git a/packages/astro/performance/fixtures/mdx/src/env.d.ts b/packages/astro/performance/fixtures/mdx/src/env.d.ts new file mode 100644 index 000000000..acef35f17 --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/astro/performance/fixtures/mdx/src/pages/index.astro b/packages/astro/performance/fixtures/mdx/src/pages/index.astro new file mode 100644 index 000000000..efb43284e --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/src/pages/index.astro @@ -0,0 +1,12 @@ +--- +import { getCollection } from 'astro:content'; +import { RenderContent } from '@performance/utils'; +import ContentRenderer from '../ContentRenderer.astro'; + +const posts = await getCollection('generated'); +--- + + diff --git a/packages/astro/performance/fixtures/mdx/tsconfig.json b/packages/astro/performance/fixtures/mdx/tsconfig.json new file mode 100644 index 000000000..7fb90fafc --- /dev/null +++ b/packages/astro/performance/fixtures/mdx/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file diff --git a/packages/astro/performance/fixtures/utils/Aside.astro b/packages/astro/performance/fixtures/utils/Aside.astro new file mode 100644 index 000000000..5d92a0993 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/Aside.astro @@ -0,0 +1,116 @@ +--- +// Inspired by the `Aside` component from docs.astro.build +// https://github.com/withastro/docs/blob/main/src/components/Aside.astro + +export interface Props { + type?: 'note' | 'tip' | 'caution' | 'danger'; + title?: string; +} + +const labelByType = { + note: 'Note', + tip: 'Tip', + caution: 'Caution', + danger: 'Danger', +}; +const { type = 'note' } = Astro.props as Props; +const title = Astro.props.title ?? labelByType[type] ?? ''; + +// SVG icon paths based on GitHub Octicons +const icons: Record, { viewBox: string; d: string }> = { + note: { + viewBox: '0 0 18 18', + d: 'M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0114.25 14H1.75A1.75 1.75 0 010 12.25v-8.5zm1.75-.25a.25.25 0 00-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-8.5a.25.25 0 00-.25-.25H1.75zM3.5 6.25a.75.75 0 01.75-.75h7a.75.75 0 010 1.5h-7a.75.75 0 01-.75-.75zm.75 2.25a.75.75 0 000 1.5h4a.75.75 0 000-1.5h-4z', + }, + tip: { + viewBox: '0 0 18 18', + d: 'M14 0a8.8 8.8 0 0 0-6 2.6l-.5.4-.9 1H3.3a1.8 1.8 0 0 0-1.5.8L.1 7.6a.8.8 0 0 0 .4 1.1l3.1 1 .2.1 2.4 2.4.1.2 1 3a.8.8 0 0 0 1 .5l2.9-1.7a1.8 1.8 0 0 0 .8-1.5V9.5l1-1 .4-.4A8.8 8.8 0 0 0 16 2v-.1A1.8 1.8 0 0 0 14.2 0h-.1zm-3.5 10.6-.3.2L8 12.3l.5 1.8 2-1.2a.3.3 0 0 0 .1-.2v-2zM3.7 8.1l1.5-2.3.2-.3h-2a.3.3 0 0 0-.3.1l-1.2 2 1.8.5zm5.2-4.5a7.3 7.3 0 0 1 5.2-2.1h.1a.3.3 0 0 1 .3.3v.1a7.3 7.3 0 0 1-2.1 5.2l-.5.4a15.2 15.2 0 0 1-2.5 2L7.1 11 5 9l1.5-2.3a15.3 15.3 0 0 1 2-2.5l.4-.5zM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-8.4 9.6a1.5 1.5 0 1 0-2.2-2.2 7 7 0 0 0-1.1 3 .2.2 0 0 0 .3.3c.6 0 2.2-.4 3-1.1z', + }, + caution: { + viewBox: '-1 1 18 18', + d: 'M8.9 1.5C8.7 1.2 8.4 1 8 1s-.7.2-.9.5l-7 12a1 1 0 0 0 0 1c.2.3.6.5 1 .5H15c.4 0 .7-.2.9-.5a1 1 0 0 0 0-1l-7-12zM9 13H7v-2h2v2zm0-3H7V6h2v4z', + }, + danger: { + viewBox: '0 1 14 17', + d: 'M5 .3c.9 2.2.5 3.4-.5 4.3C3.5 5.6 2 6.5 1 8c-1.5 2-1.7 6.5 3.5 7.7-2.2-1.2-2.6-4.5-.3-6.6-.6 2 .6 3.3 2 2.8 1.4-.4 2.3.6 2.2 1.7 0 .8-.3 1.4-1 1.8A5.6 5.6 0 0 0 12 10c0-2.9-2.5-3.3-1.3-5.7-1.5.2-2 1.2-1.8 2.8 0 1-1 1.8-2 1.3-.6-.4-.6-1.2 0-1.8C8.2 5.3 8.7 2.5 5 .3z', + }, +}; +const { viewBox, d } = icons[type]; +--- + + + + diff --git a/packages/astro/performance/fixtures/utils/Heading.astro b/packages/astro/performance/fixtures/utils/Heading.astro new file mode 100644 index 000000000..a6c29cb88 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/Heading.astro @@ -0,0 +1,20 @@ +--- +type Props = { + level: number; +} +const { level } = Astro.props; +--- + +{level === 1 &&

} +{level === 2 &&

} +{level === 3 &&

} +{level === 4 &&

} +{level === 5 &&
} +{level === 6 &&
} + + diff --git a/packages/astro/performance/fixtures/utils/HydratedLikeButton.astro b/packages/astro/performance/fixtures/utils/HydratedLikeButton.astro new file mode 100644 index 000000000..d0f1930a2 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/HydratedLikeButton.astro @@ -0,0 +1,12 @@ +--- +import LikeButton from "./LikeButton"; + +type Props = { + liked: boolean; +} + +--- + + + + diff --git a/packages/astro/performance/fixtures/utils/LikeButton.tsx b/packages/astro/performance/fixtures/utils/LikeButton.tsx new file mode 100644 index 000000000..83d3c4098 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/LikeButton.tsx @@ -0,0 +1,11 @@ +/** @jsxImportSource react */ +import { useState } from 'react'; + +export default function LikeButton({ liked: likedInitial }: {liked: boolean}) { + const [liked, setLiked] = useState(likedInitial); + return ( + + ) +} diff --git a/packages/astro/performance/fixtures/utils/RenderContent.astro b/packages/astro/performance/fixtures/utils/RenderContent.astro new file mode 100644 index 000000000..e9cfcf9a6 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/RenderContent.astro @@ -0,0 +1,31 @@ +--- +type Props = { + posts: any[]; + renderer: any; +} + +const { posts, renderer: ContentRenderer } = Astro.props; +--- + + + + + + + + Md render test + + + { + posts.map((entry) => { + return ( +
+

{entry.data.title}

+

{entry.data.description}

+ +
+ ); + }) + } + + diff --git a/packages/astro/performance/fixtures/utils/index.ts b/packages/astro/performance/fixtures/utils/index.ts new file mode 100644 index 000000000..367ba9b9e --- /dev/null +++ b/packages/astro/performance/fixtures/utils/index.ts @@ -0,0 +1,6 @@ +// @ts-nocheck +export { default as RenderContent } from './RenderContent.astro'; +export { default as Aside } from './Aside.astro'; +export { default as Heading } from './Heading.astro'; +export { default as LikeButton } from './LikeButton'; +export { default as HydratedLikeButton } from './HydratedLikeButton.astro'; diff --git a/packages/astro/performance/fixtures/utils/package.json b/packages/astro/performance/fixtures/utils/package.json new file mode 100644 index 000000000..1e3ef6b65 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/package.json @@ -0,0 +1,19 @@ +{ + "name": "@performance/utils", + "version": "0.0.0", + "description": "", + "main": "index.js", + "type": "module", + "private": true, + "keywords": [], + "author": "", + "license": "unlicensed", + "devDependencies": { + "astro": "^2.0.12", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "exports": { + ".": "./index.ts" + } +} diff --git a/packages/astro/performance/fixtures/utils/tsconfig.json b/packages/astro/performance/fixtures/utils/tsconfig.json new file mode 100644 index 000000000..33fcab125 --- /dev/null +++ b/packages/astro/performance/fixtures/utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} diff --git a/packages/astro/performance/package.json b/packages/astro/performance/package.json new file mode 100644 index 000000000..9069c0f86 --- /dev/null +++ b/packages/astro/performance/package.json @@ -0,0 +1,19 @@ +{ + "name": "@test/performance", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "benchmark": "node ./content-benchmark.mjs" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@types/yargs-parser": "^21.0.0", + "cross-env": "^7.0.3", + "kleur": "^4.1.5", + "npm-run-all": "^4.1.5", + "yargs-parser": "^21.0.1" + } +} diff --git a/packages/astro/performance/scripts/generate-posts.cli.mjs b/packages/astro/performance/scripts/generate-posts.cli.mjs new file mode 100644 index 000000000..30f22e7b7 --- /dev/null +++ b/packages/astro/performance/scripts/generate-posts.cli.mjs @@ -0,0 +1,18 @@ +/* eslint-disable no-console */ + +import { generatePosts } from './generate-posts.mjs'; + +(async () => { + const postsDir = process.argv[2]; + const numPosts = + typeof process.argv[3] === 'string' && process.argv[3].length > 0 + ? Number(process.argv[3]) + : undefined; + + const ext = process.argv[4]; + const template = process.argv[5]; + + await generatePosts({ postsDir, numPosts, ext, template }); + + console.log(`${numPosts} ${ext} posts written to ${JSON.stringify(postsDir)} πŸš€`); +})(); diff --git a/packages/astro/performance/scripts/generate-posts.mjs b/packages/astro/performance/scripts/generate-posts.mjs new file mode 100644 index 000000000..421fbe5c2 --- /dev/null +++ b/packages/astro/performance/scripts/generate-posts.mjs @@ -0,0 +1,30 @@ +import fs from 'fs'; +import path from 'path'; + +const NUM_POSTS = 10; +const POSTS_DIR = './src/content/posts.generated'; +const EXT = '.md'; +const TEMPLATE = 'simple.md'; + +export async function generatePosts({ + postsDir = POSTS_DIR, + numPosts = NUM_POSTS, + ext = EXT, + template = TEMPLATE, +}) { + if (fs.existsSync(postsDir)) { + const files = await fs.promises.readdir(postsDir); + await Promise.all(files.map((file) => fs.promises.unlink(path.join(postsDir, file)))); + } else { + await fs.promises.mkdir(postsDir, { recursive: true }); + } + + await Promise.all( + Array.from(Array(numPosts).keys()).map((idx) => { + return fs.promises.writeFile( + `${postsDir}/post-${idx}${ext.startsWith('.') ? ext : `.${ext}`}`, + fs.readFileSync(new URL(`./templates/${template}`, import.meta.url), 'utf8') + ); + }) + ); +} diff --git a/packages/astro/performance/scripts/templates/simple.md b/packages/astro/performance/scripts/templates/simple.md new file mode 100644 index 000000000..9ddbda079 --- /dev/null +++ b/packages/astro/performance/scripts/templates/simple.md @@ -0,0 +1,43 @@ +--- +title: Simple +--- + +# Simple post + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur interdum quam vitae est dapibus auctor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Vivamus id purus vel ante interdum eleifend non sed magna. Nullam aliquet metus eget nunc pretium, ac malesuada elit ultricies. Quisque fermentum tellus sed risus tristique tincidunt. Interdum et malesuada fames ac ante ipsum primis in faucibus. Maecenas eleifend odio sed tortor rhoncus venenatis. Maecenas dignissim convallis sem et sagittis. Aliquam facilisis auctor consectetur. Morbi vulputate fermentum lobortis. Aenean luctus risus erat, sit amet imperdiet lectus tempor et. + +Aliquam erat volutpat. Vivamus sodales auctor hendrerit. Proin sollicitudin, neque id volutpat ultrices, urna tellus maximus quam, at placerat diam quam a nisl. In commodo, nibh quis rhoncus lacinia, felis nisi egestas tortor, dictum mollis magna massa at tortor. Cras tempus eleifend turpis, nec suscipit velit egestas a. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Suspendisse nec nulla accumsan, sollicitudin dolor non, varius ipsum. Sed vel congue felis, sit amet bibendum neque. Pellentesque ut diam mollis augue auctor venenatis. Sed vitae aliquet lacus. Proin rutrum eget urna in vehicula. Vestibulum malesuada quis velit ac imperdiet. Donec interdum posuere nisl in auctor. Integer auctor pretium posuere. + +Aenean tincidunt vitae augue id lacinia. Fusce a lorem accumsan, luctus magna non, fermentum arcu. Quisque mattis nibh ut ultrices vehicula. Fusce at porta mauris, eu sollicitudin augue. Curabitur tempor ante vulputate posuere interdum. Nam volutpat odio in blandit dapibus. Aliquam sit amet rutrum tortor. + +Nulla eu odio nisl. Quisque malesuada arcu quis velit fermentum condimentum. Suspendisse potenti. Nullam non egestas sem. Sed et mi pharetra, ornare nunc ultricies, cursus est. Cras dignissim nisl eleifend nisl condimentum placerat. Vivamus tristique mattis vestibulum. + +Maecenas at convallis dui. Pellentesque ac convallis libero. Mauris elementum arcu in quam pulvinar, a tincidunt dolor volutpat. Donec posuere eros ac nunc aliquam, non iaculis purus faucibus. Maecenas non lacus eu elit hendrerit fringilla eget vitae justo. Donec non lorem eu libero placerat volutpat. Vivamus euismod tristique lacus quis tincidunt. + +Morbi a ligula eu odio dictum pharetra. Vestibulum a leo sit amet urna sodales facilisis posuere pretium lorem. Duis consectetur elementum sodales. Ut id libero quis dui laoreet faucibus eget ac felis. Suspendisse eu augue facilisis, consequat ex at, malesuada justo. Fusce tempor convallis orci a tristique. Pellentesque dapibus magna in sapien congue pharetra. Suspendisse potenti. Fusce in tortor justo. In hac habitasse platea dictumst. Pellentesque ligula odio, auctor vel consectetur quis, egestas a lectus. Sed arcu sapien, venenatis vitae nunc vitae, feugiat consequat elit. + +Fusce bibendum odio tellus, ac consequat magna fringilla nec. Donec sed purus at magna pulvinar iaculis ac at nulla. Cras placerat, velit quis suscipit malesuada, eros dui ultrices sapien, sodales imperdiet enim ipsum vitae nisi. Mauris malesuada pretium nibh et luctus. Suspendisse potenti. In ante nibh, euismod at diam in, dapibus facilisis nunc. Suspendisse eleifend mollis dolor sit amet tristique. Nulla mattis tempor urna, nec pellentesque ante feugiat ut. Curabitur eleifend purus sed justo facilisis lacinia. Etiam maximus magna rhoncus quam tincidunt sollicitudin. Proin rhoncus metus lacus, non euismod mi gravida ac. Nam ac ipsum nec ante ultrices tempus ac mollis erat. Quisque ac tortor dolor. Integer eros mi, porttitor at rutrum ut, cursus sit amet ex. Pellentesque sed tortor vitae lorem malesuada gravida. Pellentesque bibendum ex nunc, non cursus lorem viverra gravida. + +Integer lobortis erat quis dolor maximus porta. Sed ipsum est, maximus sit amet hendrerit ac, euismod quis nisi. Sed tincidunt, nisi sit amet varius tempus, turpis nisi sodales ante, sed ultricies urna neque vel purus. Maecenas sed laoreet tortor. Pellentesque enim massa, cursus in mauris vitae, facilisis egestas nisi. Mauris non ultrices purus, nec cursus diam. Proin eget ullamcorper augue. Pellentesque vehicula, sem vel dapibus tempus, neque tortor euismod libero, sed euismod diam enim id sapien. Sed mauris tellus, pretium eu ornare vitae, rhoncus at quam. Donec luctus mollis justo id rutrum. Aenean efficitur arcu nisi, non dignissim massa auctor et. In egestas lobortis nisi ac pharetra. Nam ultricies ipsum ut dui porta, sed commodo arcu vestibulum. Sed in felis molestie ante sodales auctor. + +Praesent ac augue dui. Sed venenatis sit amet quam non rutrum. Vestibulum vitae tempor mi. Cras id luctus sapien, consectetur euismod magna. Nunc quis pellentesque sem, ut suscipit justo. Aliquam dignissim risus ante, vitae luctus enim vestibulum id. In hac habitasse platea dictumst. Nam rhoncus ante sed commodo porta. Ut lectus eros, porta sit amet velit vitae, elementum dignissim nulla. Cras nec scelerisque nulla. Quisque in diam eleifend, congue nulla eu, vestibulum magna. Sed vel purus elementum, mattis nunc id, mollis arcu. Pellentesque in pellentesque ipsum, non condimentum augue. Aenean tincidunt dui ut purus aliquet pretium. Integer vitae velit aliquet, tincidunt urna sed, bibendum lorem. Vivamus sit amet sapien ut sapien rhoncus fringilla vel a mi. + +Praesent dignissim, arcu vel sollicitudin dictum, augue velit pretium ante, sit amet egestas velit lectus et tortor. In egestas ullamcorper risus, non vestibulum diam ultricies eu. Praesent a ex ac nisi consequat rhoncus. Fusce feugiat feugiat libero, vel lobortis mauris faucibus elementum. Mauris vitae luctus sapien. Etiam id pretium metus, in lacinia eros. Morbi et dictum risus. Morbi fringilla lorem ut elit fringilla blandit. + +Nullam eu nibh ipsum. Curabitur aliquet varius ante, a pretium mauris dictum in. Integer nibh arcu, tristique ac sagittis nec, maximus et ligula. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin blandit nec mi vel hendrerit. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi consequat blandit orci, sed placerat sem fermentum sit amet. Quisque eu iaculis nisl. Suspendisse quam mauris, semper vel eleifend vitae, mollis in arcu. + +Aenean porttitor blandit orci id bibendum. Nunc sit amet ligula bibendum, congue urna fringilla, dictum purus. Pellentesque blandit, nibh id laoreet placerat, mauris dui semper mi, id tincidunt metus massa nec nisi. Suspendisse potenti. Phasellus ut risus velit. Curabitur porttitor metus nunc, in malesuada justo gravida sit amet. Cras auctor justo magna, ut eleifend turpis dictum id. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Pellentesque ut augue ac velit imperdiet suscipit non at massa. + +Suspendisse euismod ornare quam id varius. Etiam ac velit id quam accumsan imperdiet sit amet eu nibh. Ut nec massa ultricies enim iaculis feugiat. Phasellus vehicula massa id ligula dapibus, sit amet viverra justo volutpat. Sed nunc est, efficitur et purus id, lacinia pellentesque metus. Pellentesque mi quam, maximus a blandit nec, mollis eget leo. Nulla sit amet elementum augue. Aenean id luctus nisl. Etiam non ante id augue dignissim suscipit in id quam. Quisque non consequat diam, eget condimentum turpis. Donec fringilla metus eget condimentum congue. Pellentesque aliquet blandit posuere. In bibendum ultrices ex a ornare. Donec quis efficitur metus. In commodo sollicitudin turpis et efficitur. Ut ac viverra nunc, sit amet varius sapien. + +In sit amet felis et diam vehicula placerat. Nullam finibus lorem libero, et pretium eros consectetur euismod. In fringilla semper diam et hendrerit. Phasellus id erat at justo imperdiet aliquet. Donec dignissim auctor nunc, et ultrices ex rutrum nec. Aliquam ut cursus leo. Suspendisse semper velit ac lorem aliquet fermentum. Suspendisse congue mi et ultrices bibendum. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Donec ac neque et enim facilisis posuere volutpat a augue. Vivamus feugiat fermentum rhoncus. + +Proin ultricies turpis non mauris finibus elementum. Cras scelerisque pretium justo non efficitur. Curabitur at risus ut velit ullamcorper fringilla congue in nulla. Nunc laoreet lacinia purus at lobortis. Sed vulputate ex non cursus accumsan. Morbi risus elit, porttitor ac hendrerit sed, commodo suscipit nisi. Vivamus vestibulum ex sapien, sagittis blandit velit fermentum et. + +Suspendisse ut ullamcorper ex, a hendrerit elit. Vivamus gravida tempor efficitur. Ut lobortis neque a mollis efficitur. Donec sit amet arcu quis massa fringilla consequat. Duis vitae nisl laoreet, suscipit tellus nec, malesuada sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Phasellus in sollicitudin nisi. Fusce id sapien ac nunc sagittis lobortis tempor auctor eros. Nunc lacinia enim vitae massa lacinia, suscipit facilisis turpis tempus. Integer nec mollis ex. Pellentesque magna nisl, dignissim in mi quis, malesuada elementum nibh. Duis consectetur erat quis interdum ornare. Phasellus lorem felis, aliquam a nunc at, luctus faucibus odio. In hac habitasse platea dictumst. + +Vestibulum sit amet lorem arcu. Integer sed nisl ut turpis dapibus mollis sit amet sed turpis. Donec massa dolor, blandit at lacinia eu, ultricies eu turpis. Sed mollis non diam non consectetur. Morbi suscipit metus at orci sagittis ultricies. Mauris pulvinar maximus ex vitae convallis. Ut imperdiet vehicula mi ut imperdiet. Aliquam et dui at turpis volutpat condimentum. Morbi laoreet scelerisque leo, non tristique ante convallis vulputate. Nam et lorem enim. Cras cursus sodales nisi, nec facilisis felis feugiat sit amet. Aenean consequat pellentesque magna id venenatis. Nunc sed quam consequat, vestibulum diam nec, dignissim justo. Duis vulputate nibh sit amet tortor lobortis iaculis. Curabitur pellentesque dui sapien, nec varius libero hendrerit vel. + +Curabitur quis mi ac massa hendrerit ornare id eget velit. Nulla dui lacus, hendrerit et fringilla sed, eleifend ut erat. Nunc ut fringilla ex, sit amet fringilla libero. Maecenas non ullamcorper orci. Duis posuere erat et urna rhoncus iaculis. Proin pellentesque porttitor nulla, non blandit ante semper vitae. Phasellus ut augue venenatis, tempus purus eu, efficitur massa. Etiam vel egestas tellus, ac pharetra lectus. Aliquam non commodo turpis. Quisque pharetra nunc et mauris bibendum, id vestibulum tellus fringilla. Nullam enim massa, porta id nisi at, accumsan sollicitudin elit. Morbi auctor lectus vitae orci cursus, et hendrerit odio accumsan. Pellentesque quis libero auctor, tempor dolor tempor, finibus arcu. Aliquam non cursus ex. Aliquam quis lacus ut purus pellentesque ultrices in a augue. + +Morbi nunc diam, egestas sed condimentum a, interdum suscipit ligula. Morbi interdum dignissim imperdiet. Interdum et malesuada fames ac ante ipsum primis in faucibus. Mauris egestas, nulla nec feugiat porttitor, ex magna sodales nisl, ac volutpat tortor mauris vitae nibh. Cras cursus dignissim pretium. Nunc faucibus dui at lectus pellentesque vehicula. Maecenas tincidunt, libero quis hendrerit aliquet, tortor leo iaculis enim, sit amet ullamcorper tellus risus a orci. Donec dignissim metus in nulla eleifend molestie. Nunc at turpis et sem laoreet rutrum. Nulla facilisi. Sed luctus nisi sed egestas cursus. diff --git a/packages/astro/performance/scripts/templates/with-astro-components.mdoc b/packages/astro/performance/scripts/templates/with-astro-components.mdoc new file mode 100644 index 000000000..60f99d149 --- /dev/null +++ b/packages/astro/performance/scripts/templates/with-astro-components.mdoc @@ -0,0 +1,19 @@ +--- +type: with-astro-components +title: Post with Astro components +--- + +# This should be rendered with a title component + +{% aside type="tip" %} +This is a tip component +{% /aside %} +{% aside type="note" %} +This is a note component +{% /aside %} +{% aside type="caution" %} +This is a caution component +{% /aside %} +{% aside type="danger" %} +This is a danger component +{% /aside %} diff --git a/packages/astro/performance/scripts/templates/with-astro-components.mdx b/packages/astro/performance/scripts/templates/with-astro-components.mdx new file mode 100644 index 000000000..9e683fee5 --- /dev/null +++ b/packages/astro/performance/scripts/templates/with-astro-components.mdx @@ -0,0 +1,13 @@ +--- +type: with-astro-components +title: Post with Astro components +--- + +import { Aside } from '@performance/utils'; + +# This should be rendered with a title component + + + + + diff --git a/packages/astro/performance/scripts/templates/with-react-components.mdoc b/packages/astro/performance/scripts/templates/with-react-components.mdoc new file mode 100644 index 000000000..2cbc74ac5 --- /dev/null +++ b/packages/astro/performance/scripts/templates/with-react-components.mdoc @@ -0,0 +1,12 @@ +--- +type: with-react-components +title: Post with React components +--- + +# This render clickable like components + +{% like-button liked=true /%} +{% like-button liked=false /%} + +{% hydrated-like-button liked=true /%} +{% hydrated-like-button liked=false /%} diff --git a/packages/astro/performance/scripts/templates/with-react-components.mdx b/packages/astro/performance/scripts/templates/with-react-components.mdx new file mode 100644 index 000000000..25fa9a6f3 --- /dev/null +++ b/packages/astro/performance/scripts/templates/with-react-components.mdx @@ -0,0 +1,14 @@ +--- +type: with-react-components +title: Post with React components +--- + +import { LikeButton, HydratedLikeButton } from '@performance/utils'; + +# This render clickable like components + + + + + + diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 95e027d46..7dbcf183d 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1012,12 +1012,33 @@ export interface AstroConfig extends z.output { integrations: AstroIntegration[]; } +export interface ContentEntryType { + extensions: string[]; + getEntryInfo(params: { + fileUrl: URL; + contents: string; + }): GetEntryInfoReturnType | Promise; + contentModuleTypes?: string; +} + +type GetEntryInfoReturnType = { + data: Record; + /** + * Used for error hints to point to correct line and location + * Should be the untouched data as read from the file, + * including newlines + */ + rawData: string; + body: string; + slug: string; +} + export interface AstroSettings { config: AstroConfig; - adapter: AstroAdapter | undefined; injectedRoutes: InjectedRoute[]; pageExtensions: string[]; + contentEntryTypes: ContentEntryType[]; renderers: AstroRenderer[]; scripts: { stage: InjectedScriptStage; diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts index 9966e7121..1f0470d5a 100644 --- a/packages/astro/src/content/consts.ts +++ b/packages/astro/src/content/consts.ts @@ -1,4 +1,3 @@ -export const contentFileExts = ['.md', '.mdx']; export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets'; export const CONTENT_FLAG = 'astroContent'; export const VIRTUAL_MODULE_ID = 'astro:content'; diff --git a/packages/astro/src/content/internal.ts b/packages/astro/src/content/internal.ts index 36560d33b..2fdfe7454 100644 --- a/packages/astro/src/content/internal.ts +++ b/packages/astro/src/content/internal.ts @@ -186,8 +186,8 @@ async function render({ return { Content, - headings: mod.getHeadings(), - remarkPluginFrontmatter: mod.frontmatter, + headings: mod.getHeadings?.() ?? [], + remarkPluginFrontmatter: mod.frontmatter ?? {}, }; } diff --git a/packages/astro/src/content/template/types.d.ts b/packages/astro/src/content/template/types.d.ts index 83c805c4e..0651773dd 100644 --- a/packages/astro/src/content/template/types.d.ts +++ b/packages/astro/src/content/template/types.d.ts @@ -1,7 +1,17 @@ +declare module 'astro:content' { + interface Render { + '.md': Promise<{ + Content: import('astro').MarkdownInstance<{}>['Content']; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + }>; + } +} + declare module 'astro:content' { export { z } from 'astro/zod'; export type CollectionEntry = - (typeof entryMap)[C][keyof (typeof entryMap)[C]] & Render; + (typeof entryMap)[C][keyof (typeof entryMap)[C]]; export const image: () => import('astro/zod').ZodObject<{ src: import('astro/zod').ZodString; @@ -64,14 +74,6 @@ declare module 'astro:content' { Required['schema'] >; - type Render = { - render(): Promise<{ - Content: import('astro').MarkdownInstance<{}>['Content']; - headings: import('astro').MarkdownHeading[]; - remarkPluginFrontmatter: Record; - }>; - }; - const entryMap: { // @@ENTRY_MAP@@ }; diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 8d990d586..753b5974b 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -4,7 +4,7 @@ import type fsMod from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { normalizePath, ViteDevServer } from 'vite'; -import type { AstroSettings } from '../@types/astro.js'; +import type { AstroSettings, ContentEntryType } from '../@types/astro.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { info, LogOptions, warn } from '../core/logger/core.js'; import { isRelativePath } from '../core/path.js'; @@ -14,6 +14,7 @@ import { ContentObservable, ContentPaths, EntryInfo, + getContentEntryExts, getContentPaths, getEntryInfo, getEntrySlug, @@ -57,11 +58,12 @@ export async function createContentTypesGenerator({ }: CreateContentGeneratorParams) { const contentTypes: ContentTypes = {}; const contentPaths = getContentPaths(settings.config, fs); + const contentEntryExts = getContentEntryExts(settings); let events: EventWithOptions[] = []; let debounceTimeout: NodeJS.Timeout | undefined; - const contentTypesBase = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8'); + const typeTemplateContent = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8'); async function init(): Promise< { typesGenerated: true } | { typesGenerated: false; reason: 'no-content-dir' } @@ -121,7 +123,7 @@ export async function createContentTypesGenerator({ } return { shouldGenerateTypes: true }; } - const fileType = getEntryType(fileURLToPath(event.entry), contentPaths); + const fileType = getEntryType(fileURLToPath(event.entry), contentPaths, contentEntryExts); if (fileType === 'ignored') { return { shouldGenerateTypes: false }; } @@ -261,8 +263,9 @@ export async function createContentTypesGenerator({ fs, contentTypes, contentPaths, - contentTypesBase, + typeTemplateContent, contentConfig: observable.status === 'loaded' ? observable.config : undefined, + contentEntryTypes: settings.contentEntryTypes, }); if (observable.status === 'loaded' && ['info', 'warn'].includes(logLevel)) { warnNonexistentCollections({ @@ -300,7 +303,7 @@ async function parseSlug({ // on dev server startup or production build init. const rawContents = await fs.promises.readFile(event.entry, 'utf-8'); const { data: frontmatter } = parseFrontmatter(rawContents, fileURLToPath(event.entry)); - return getEntrySlug({ ...entryInfo, data: frontmatter }); + return getEntrySlug({ ...entryInfo, unvalidatedSlug: frontmatter.slug }); } function setEntry( @@ -320,13 +323,15 @@ async function writeContentFiles({ fs, contentPaths, contentTypes, - contentTypesBase, + typeTemplateContent, + contentEntryTypes, contentConfig, }: { fs: typeof fsMod; contentPaths: ContentPaths; contentTypes: ContentTypes; - contentTypesBase: string; + typeTemplateContent: string; + contentEntryTypes: ContentEntryType[]; contentConfig?: ContentConfig; }) { let contentTypesStr = ''; @@ -338,8 +343,11 @@ async function writeContentFiles({ for (const entryKey of entryKeys) { const entryMetadata = contentTypes[collectionKey][entryKey]; const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any'; + const renderType = `{ render(): Render[${JSON.stringify( + path.extname(JSON.parse(entryKey)) + )}] }`; const slugType = JSON.stringify(entryMetadata.slug); - contentTypesStr += `${entryKey}: {\n id: ${entryKey},\n slug: ${slugType},\n body: string,\n collection: ${collectionKey},\n data: ${dataType}\n},\n`; + contentTypesStr += `${entryKey}: {\n id: ${entryKey},\n slug: ${slugType},\n body: string,\n collection: ${collectionKey},\n data: ${dataType}\n} & ${renderType},\n`; } contentTypesStr += `},\n`; } @@ -359,13 +367,21 @@ async function writeContentFiles({ configPathRelativeToCacheDir = configPathRelativeToCacheDir.replace(/\.ts$/, ''); } - contentTypesBase = contentTypesBase.replace('// @@ENTRY_MAP@@', contentTypesStr); - contentTypesBase = contentTypesBase.replace( + for (const contentEntryType of contentEntryTypes) { + if (contentEntryType.contentModuleTypes) { + typeTemplateContent = contentEntryType.contentModuleTypes + '\n' + typeTemplateContent; + } + } + typeTemplateContent = typeTemplateContent.replace('// @@ENTRY_MAP@@', contentTypesStr); + typeTemplateContent = typeTemplateContent.replace( "'@@CONTENT_CONFIG_TYPE@@'", contentConfig ? `typeof import(${JSON.stringify(configPathRelativeToCacheDir)})` : 'never' ); - await fs.promises.writeFile(new URL(CONTENT_TYPES_FILE, contentPaths.cacheDir), contentTypesBase); + await fs.promises.writeFile( + new URL(CONTENT_TYPES_FILE, contentPaths.cacheDir), + typeTemplateContent + ); } function warnNonexistentCollections({ diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 09edc2165..011ade19f 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import { AstroConfig, AstroSettings } from '../@types/astro.js'; import type { ImageMetadata } from '../assets/types.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; -import { contentFileExts, CONTENT_TYPES_FILE } from './consts.js'; +import { CONTENT_TYPES_FILE } from './consts.js'; export const collectionConfigParser = z.object({ schema: z.any().optional(), @@ -30,14 +30,7 @@ export const contentConfigParser = z.object({ export type CollectionConfig = z.infer; export type ContentConfig = z.infer; -type Entry = { - id: string; - collection: string; - slug: string; - data: any; - body: string; - _internal: { rawData: string; filePath: string }; -}; +type EntryInternal = { rawData: string; filePath: string }; export type EntryInfo = { id: string; @@ -71,10 +64,10 @@ export function getEntrySlug({ id, collection, slug, - data: unparsedData, -}: Pick) { + unvalidatedSlug, +}: EntryInfo & { unvalidatedSlug?: unknown }) { try { - return z.string().default(slug).parse(unparsedData.slug); + return z.string().default(slug).parse(unvalidatedSlug); } catch { throw new AstroError({ ...AstroErrorData.InvalidContentEntrySlugError, @@ -83,9 +76,12 @@ export function getEntrySlug({ } } -export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) { +export async function getEntryData( + entry: EntryInfo & { unvalidatedData: Record; _internal: EntryInternal }, + collectionConfig: CollectionConfig +) { // Remove reserved `slug` field before parsing data - let { slug, ...data } = entry.data; + let { slug, ...data } = entry.unvalidatedData; if (collectionConfig.schema) { // TODO: remove for 2.0 stable release if ( @@ -112,7 +108,9 @@ export async function getEntryData(entry: Entry, collectionConfig: CollectionCon }); } // Use `safeParseAsync` to allow async transforms - const parsed = await collectionConfig.schema.safeParseAsync(entry.data, { errorMap }); + const parsed = await collectionConfig.schema.safeParseAsync(entry.unvalidatedData, { + errorMap, + }); if (parsed.success) { data = parsed.data; } else { @@ -138,6 +136,10 @@ export async function getEntryData(entry: Entry, collectionConfig: CollectionCon return data; } +export function getContentEntryExts(settings: Pick) { + return settings.contentEntryTypes.map((t) => t.extensions).flat(); +} + export class NoCollectionError extends Error {} export function getEntryInfo( @@ -178,14 +180,15 @@ export function getEntryInfo({ export function getEntryType( entryPath: string, - paths: Pick + paths: Pick, + contentFileExts: string[] ): 'content' | 'config' | 'ignored' | 'unsupported' { const { ext, base } = path.parse(entryPath); const fileUrl = pathToFileURL(entryPath); if (hasUnderscoreBelowContentDirectoryPath(fileUrl, paths.contentDir) || isOnIgnoreList(base)) { return 'ignored'; - } else if ((contentFileExts as readonly string[]).includes(ext)) { + } else if (contentFileExts.includes(ext)) { return 'content'; } else if (fileUrl.href === paths.config.url.href) { return 'config'; diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 3a48edfc1..cda8aff64 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -1,6 +1,7 @@ import npath from 'node:path'; import { pathToFileURL } from 'url'; import type { Plugin } from 'vite'; +import { AstroSettings } from '../@types/astro.js'; import { moduleIsTopLevelPage, walkParentInfos } from '../core/build/graph.js'; import { BuildInternals, getPageDataByViteID } from '../core/build/internal.js'; import { AstroBuildPlugin } from '../core/build/plugin.js'; @@ -11,23 +12,30 @@ import { prependForwardSlash } from '../core/path.js'; import { getStylesForURL } from '../core/render/dev/css.js'; import { getScriptsForURL } from '../core/render/dev/scripts.js'; import { - contentFileExts, LINKS_PLACEHOLDER, PROPAGATED_ASSET_FLAG, SCRIPTS_PLACEHOLDER, STYLES_PLACEHOLDER, } from './consts.js'; +import { getContentEntryExts } from './utils.js'; -function isPropagatedAsset(viteId: string): boolean { +function isPropagatedAsset(viteId: string, contentEntryExts: string[]): boolean { const url = new URL(viteId, 'file://'); return ( url.searchParams.has(PROPAGATED_ASSET_FLAG) && - contentFileExts.some((ext) => url.pathname.endsWith(ext)) + contentEntryExts.some((ext) => url.pathname.endsWith(ext)) ); } -export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): Plugin { +export function astroContentAssetPropagationPlugin({ + mode, + settings, +}: { + mode: string; + settings: AstroSettings; +}): Plugin { let devModuleLoader: ModuleLoader; + const contentEntryExts = getContentEntryExts(settings); return { name: 'astro:content-asset-propagation', enforce: 'pre', @@ -37,7 +45,7 @@ export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): } }, load(id) { - if (isPropagatedAsset(id)) { + if (isPropagatedAsset(id, contentEntryExts)) { const basePath = id.split('?')[0]; const code = ` export async function getMod() { @@ -52,7 +60,7 @@ export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): }, async transform(code, id, options) { if (!options?.ssr) return; - if (devModuleLoader && isPropagatedAsset(id)) { + if (devModuleLoader && isPropagatedAsset(id, contentEntryExts)) { const basePath = id.split('?')[0]; if (!devModuleLoader.getModuleById(basePath)?.ssrModule) { await devModuleLoader.import(basePath); diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index cd3c11214..194b19cc4 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -1,15 +1,17 @@ import * as devalue from 'devalue'; import type fsMod from 'node:fs'; +import { extname } from 'node:path'; import { pathToFileURL } from 'url'; import type { Plugin } from 'vite'; import { normalizePath } from 'vite'; -import { AstroSettings } from '../@types/astro.js'; +import { AstroSettings, ContentEntryType } from '../@types/astro.js'; import { AstroErrorData } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/errors.js'; import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; -import { contentFileExts, CONTENT_FLAG } from './consts.js'; +import { CONTENT_FLAG } from './consts.js'; import { ContentConfig, + getContentEntryExts, extractFrontmatterAssets, getContentPaths, getEntryData, @@ -20,9 +22,9 @@ import { parseFrontmatter, } from './utils.js'; -function isContentFlagImport(viteId: string) { - const { pathname, searchParams } = new URL(viteId, 'file://'); - return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext)); +function isContentFlagImport(viteId: string, contentEntryExts: string[]) { + const { searchParams, pathname } = new URL(viteId, 'file://'); + return searchParams.has(CONTENT_FLAG) && contentEntryExts.some((ext) => pathname.endsWith(ext)); } export function astroContentImportPlugin({ @@ -33,12 +35,20 @@ export function astroContentImportPlugin({ settings: AstroSettings; }): Plugin { const contentPaths = getContentPaths(settings.config, fs); + const contentEntryExts = getContentEntryExts(settings); + + const contentEntryExtToParser: Map = new Map(); + for (const entryType of settings.contentEntryTypes) { + for (const ext of entryType.extensions) { + contentEntryExtToParser.set(ext, entryType); + } + } return { name: 'astro:content-imports', async load(id) { const { fileId } = getFileInfo(id, settings.config); - if (isContentFlagImport(id)) { + if (isContentFlagImport(id, contentEntryExts)) { const observable = globalContentConfigObserver.get(); // Content config should be loaded before this plugin is used @@ -71,44 +81,55 @@ export function astroContentImportPlugin({ }); } const rawContents = await fs.promises.readFile(fileId, 'utf-8'); - const { - content: body, - data: unparsedData, - matter: rawData = '', - } = parseFrontmatter(rawContents, fileId); - const entryInfo = getEntryInfo({ + const fileExt = extname(fileId); + if (!contentEntryExtToParser.has(fileExt)) { + throw new AstroError({ + ...AstroErrorData.UnknownContentCollectionError, + message: `No parser found for content entry ${JSON.stringify( + fileId + )}. Did you apply an integration for this file type?`, + }); + } + const contentEntryParser = contentEntryExtToParser.get(fileExt)!; + const info = await contentEntryParser.getEntryInfo({ + fileUrl: pathToFileURL(fileId), + contents: rawContents, + }); + const generatedInfo = getEntryInfo({ entry: pathToFileURL(fileId), contentDir: contentPaths.contentDir, }); - if (entryInfo instanceof Error) return; + if (generatedInfo instanceof Error) return; - const _internal = { filePath: fileId, rawData }; - const partialEntry = { data: unparsedData, body, _internal, ...entryInfo }; + const _internal = { filePath: fileId, rawData: info.rawData }; // TODO: move slug calculation to the start of the build // to generate a performant lookup map for `getEntryBySlug` - const slug = getEntrySlug(partialEntry); + const slug = getEntrySlug({ ...generatedInfo, unvalidatedSlug: info.slug }); - const collectionConfig = contentConfig?.collections[entryInfo.collection]; + const collectionConfig = contentConfig?.collections[generatedInfo.collection]; const data = collectionConfig - ? await getEntryData(partialEntry, collectionConfig) - : unparsedData; + ? await getEntryData( + { ...generatedInfo, _internal, unvalidatedData: info.data }, + collectionConfig + ) + : info.data; const images = extractFrontmatterAssets(data).map( (image) => `'${image}': await import('${normalizePath(image)}'),` ); const code = escapeViteEnvReferences(` -export const id = ${JSON.stringify(entryInfo.id)}; -export const collection = ${JSON.stringify(entryInfo.collection)}; +export const id = ${JSON.stringify(generatedInfo.id)}; +export const collection = ${JSON.stringify(generatedInfo.collection)}; export const slug = ${JSON.stringify(slug)}; -export const body = ${JSON.stringify(body)}; +export const body = ${JSON.stringify(info.body)}; const frontmatterImages = { ${images.join('\n')} } export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */}; export const _internal = { - filePath: ${JSON.stringify(fileId)}, - rawData: ${JSON.stringify(rawData)}, + filePath: ${JSON.stringify(_internal.filePath)}, + rawData: ${JSON.stringify(_internal.rawData)}, }; `); return { code }; @@ -118,11 +139,11 @@ export const _internal = { viteServer.watcher.on('all', async (event, entry) => { if ( ['add', 'unlink', 'change'].includes(event) && - getEntryType(entry, contentPaths) === 'config' + getEntryType(entry, contentPaths, contentEntryExts) === 'config' ) { // Content modules depend on config, so we need to invalidate them. for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) { - if (isContentFlagImport(modUrl)) { + if (isContentFlagImport(modUrl, contentEntryExts)) { const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl); if (mod) { viteServer.moduleGraph.invalidateModule(mod); @@ -133,7 +154,7 @@ export const _internal = { }); }, async transform(code, id) { - if (isContentFlagImport(id)) { + if (isContentFlagImport(id, contentEntryExts)) { // Escape before Rollup internal transform. // Base on MUCH trial-and-error, inspired by MDX integration 2-step transform. return { code: escapeViteEnvReferences(code) }; diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts index 32214ac27..99b6e3f3c 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -4,8 +4,8 @@ import type { Plugin } from 'vite'; import { normalizePath } from 'vite'; import type { AstroSettings } from '../@types/astro.js'; import { appendForwardSlash, prependForwardSlash } from '../core/path.js'; -import { contentFileExts, VIRTUAL_MODULE_ID } from './consts.js'; -import { getContentPaths } from './utils.js'; +import { VIRTUAL_MODULE_ID } from './consts.js'; +import { getContentEntryExts, getContentPaths } from './utils.js'; interface AstroContentVirtualModPluginParams { settings: AstroSettings; @@ -22,11 +22,17 @@ export function astroContentVirtualModPlugin({ ) ) ); + const contentEntryExts = getContentEntryExts(settings); const assetsDir = settings.config.experimental.assets ? contentPaths.assetsDir.toString() : 'undefined'; - const entryGlob = `${relContentDir}**/*{${contentFileExts.join(',')}}`; + const extGlob = + contentEntryExts.length === 1 + ? // Wrapping {...} breaks when there is only one extension + contentEntryExts[0] + : `{${contentEntryExts.join(',')}}`; + const entryGlob = `${relContentDir}**/*${extGlob}`; const virtualModContents = fsMod .readFileSync(contentPaths.virtualModTemplate, 'utf-8') .replace('@@CONTENT_DIR@@', relContentDir) diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index e6d5c9bb2..9b4b287b8 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -6,6 +6,7 @@ import jsxRenderer from '../../jsx/renderer.js'; import { createDefaultDevConfig } from './config.js'; import { AstroTimer } from './timer.js'; import { loadTSConfig } from './tsconfig.js'; +import { markdownContentEntryType } from '../../vite-plugin-markdown/content-entry-type.js'; export function createBaseSettings(config: AstroConfig): AstroSettings { return { @@ -19,6 +20,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { ? [{ pattern: '/_image', entryPoint: 'astro/assets/image-endpoint' }] : [], pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS], + contentEntryTypes: [markdownContentEntryType], renderers: [jsxRenderer], scripts: [], watchFiles: [], diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 2b540a848..7308a56df 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -116,7 +116,7 @@ export async function createVite( astroInjectEnvTsPlugin({ settings, logging, fs }), astroContentVirtualModPlugin({ settings }), astroContentImportPlugin({ fs, settings }), - astroContentAssetPropagationPlugin({ mode }), + astroContentAssetPropagationPlugin({ mode, settings }), vitePluginSSRManifest(), settings.config.experimental.assets ? [astroAssetsPlugin({ settings, logging, mode })] : [], ], diff --git a/packages/astro/src/core/errors/dev/vite.ts b/packages/astro/src/core/errors/dev/vite.ts index 3a94f9749..1ae467c2c 100644 --- a/packages/astro/src/core/errors/dev/vite.ts +++ b/packages/astro/src/core/errors/dev/vite.ts @@ -122,6 +122,10 @@ export interface AstroErrorPayload { }; } +// Shiki does not support `mjs` or `cjs` aliases by default. +// Map these to `.js` during error highlighting. +const ALTERNATIVE_JS_EXTS = ['cjs', 'mjs']; + /** * Generate a payload for Vite's error overlay */ @@ -150,9 +154,13 @@ export async function getViteErrorPayload(err: ErrorWithMetadata): Promise({ name, @@ -95,21 +97,46 @@ export async function runHookConfigSetup({ updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path); }, }; - // Semi-private `addPageExtension` hook + + // --- + // Public, intentionally undocumented hooks - not subject to semver. + // Intended for internal integrations (ex. `@astrojs/mdx`), + // though accessible to integration authors if discovered. + function addPageExtension(...input: (string | string[])[]) { const exts = (input.flat(Infinity) as string[]).map((ext) => `.${ext.replace(/^\./, '')}`); updatedSettings.pageExtensions.push(...exts); } + function addContentEntryType(contentEntryType: ContentEntryType) { + updatedSettings.contentEntryTypes.push(contentEntryType); + } + Object.defineProperty(hooks, 'addPageExtension', { value: addPageExtension, writable: false, enumerable: false, }); + Object.defineProperty(hooks, 'addContentEntryType', { + value: addContentEntryType, + writable: false, + enumerable: false, + }); + // --- + await withTakingALongTimeMsg({ name: integration.name, hookResult: integration.hooks['astro:config:setup'](hooks), logging, }); + + // Add MDX content entry type to support older `@astrojs/mdx` versions + // TODO: remove in next Astro minor release + if ( + integration.name === '@astrojs/mdx' && + !updatedSettings.contentEntryTypes.find((c) => c.extensions.includes('.mdx')) + ) { + addContentEntryType(mdxContentEntryType); + } } } diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts new file mode 100644 index 000000000..e94d18ad4 --- /dev/null +++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts @@ -0,0 +1,42 @@ +import { fileURLToPath } from 'node:url'; +import { ContentEntryType } from '../@types/astro.js'; +import { parseFrontmatter } from '../content/utils.js'; + +export const markdownContentEntryType: ContentEntryType = { + extensions: ['.md'], + async getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { + const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl)); + return { + data: parsed.data, + body: parsed.content, + slug: parsed.data.slug, + rawData: parsed.matter, + }; + }, +}; + +/** + * MDX content type for compatibility with older `@astrojs/mdx` versions + * TODO: remove in next Astro minor release + */ +export const mdxContentEntryType: ContentEntryType = { + extensions: ['.mdx'], + async getEntryInfo({ fileUrl, contents }: { fileUrl: URL; contents: string }) { + const parsed = parseFrontmatter(contents, fileURLToPath(fileUrl)); + return { + data: parsed.data, + body: parsed.content, + slug: parsed.data.slug, + rawData: parsed.matter, + }; + }, + contentModuleTypes: `declare module 'astro:content' { + interface Render { + '.mdx': Promise<{ + Content: import('astro').MarkdownInstance<{}>['Content']; + headings: import('astro').MarkdownHeading[]; + remarkPluginFrontmatter: Record; + }>; + } +}`, +}; diff --git a/packages/astro/test/fixtures/content-collections-with-config-mjs/package.json b/packages/astro/test/fixtures/content-collections-with-config-mjs/package.json index eed4ebb90..3b20bebdf 100644 --- a/packages/astro/test/fixtures/content-collections-with-config-mjs/package.json +++ b/packages/astro/test/fixtures/content-collections-with-config-mjs/package.json @@ -3,7 +3,6 @@ "version": "0.0.0", "private": true, "dependencies": { - "astro": "workspace:*", - "@astrojs/mdx": "workspace:*" + "astro": "workspace:*" } } diff --git a/packages/astro/test/units/content-collections/get-entry-type.test.js b/packages/astro/test/units/content-collections/get-entry-type.test.js index 3e549c2a2..e6ab3141d 100644 --- a/packages/astro/test/units/content-collections/get-entry-type.test.js +++ b/packages/astro/test/units/content-collections/get-entry-type.test.js @@ -25,13 +25,15 @@ const fixtures = [ }, ]; +const contentFileExts = ['.md', '.mdx']; + describe('Content Collections - getEntryType', () => { fixtures.forEach(({ title, contentPaths }) => { describe(title, () => { it('Returns "content" for Markdown files', () => { for (const entryPath of ['blog/first-post.md', 'blog/first-post.mdx']) { const entry = fileURLToPath(new URL(entryPath, contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('content'); } }); @@ -39,44 +41,44 @@ describe('Content Collections - getEntryType', () => { it('Returns "content" for Markdown files in nested directories', () => { for (const entryPath of ['blog/2021/01/01/index.md', 'blog/2021/01/01/index.mdx']) { const entry = fileURLToPath(new URL(entryPath, contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('content'); } }); it('Returns "config" for config files', () => { const entry = fileURLToPath(contentPaths.config.url); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('config'); }); it('Returns "unsupported" for non-Markdown files', () => { const entry = fileURLToPath(new URL('blog/robots.txt', contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('unsupported'); }); it('Returns "ignored" for .DS_Store', () => { const entry = fileURLToPath(new URL('blog/.DS_Store', contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('ignored'); }); it('Returns "ignored" for unsupported files using an underscore', () => { const entry = fileURLToPath(new URL('blog/_draft-robots.txt', contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('ignored'); }); it('Returns "ignored" when using underscore on file name', () => { const entry = fileURLToPath(new URL('blog/_first-post.md', contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('ignored'); }); it('Returns "ignored" when using underscore on directory name', () => { const entry = fileURLToPath(new URL('blog/_draft/first-post.md', contentPaths.contentDir)); - const type = getEntryType(entry, contentPaths); + const type = getEntryType(entry, contentPaths, contentFileExts); expect(type).to.equal('ignored'); }); }); diff --git a/packages/integrations/markdoc/README.md b/packages/integrations/markdoc/README.md new file mode 100644 index 000000000..bc3e3b53f --- /dev/null +++ b/packages/integrations/markdoc/README.md @@ -0,0 +1,348 @@ +# @astrojs/markdoc (experimental) πŸ“ + +This **[Astro integration][astro-integration]** enables the usage of [Markdoc](https://markdoc.dev/) to create components, pages, and content collection entries. + +- [Why Markdoc?](#why-markdoc) +- [Installation](#installation) +- [Usage](#usage) +- [Configuration](#configuration) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [Changelog](#changelog) + +## Why Markdoc? + +Markdoc allows you to enhance your Markdown with [Astro components][astro-components]. If you have existing content authored in Markdoc, this integration allows you to bring those files to your Astro project using content collections. + +## Installation + +### Quick Install + +The `astro add` command-line tool automates the installation for you. Run one of the following commands in a new terminal window. (If you aren't sure which package manager you're using, run the first command.) Then, follow the prompts, and type "y" in the terminal (meaning "yes") for each one. + +```sh +# Using NPM +npx astro add markdoc +# Using Yarn +yarn astro add markdoc +# Using PNPM +pnpm astro add markdoc +``` + +If you run into any issues, [feel free to report them to us on GitHub](https://github.com/withastro/astro/issues) and try the manual installation steps below. + +### Manual Install + +First, install the `@astrojs/markdoc` package using your package manager. If you're using npm or aren't sure, run this in the terminal: + +```sh +npm install @astrojs/markdoc +``` + +Then, apply this integration to your `astro.config.*` file using the `integrations` property: + +__`astro.config.mjs`__ + +```js ins={2} "markdoc()" +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +export default defineConfig({ + // ... + integrations: [markdoc()], +}); +``` + + +### Editor Integration + +[VS Code](https://code.visualstudio.com/) supports Markdown by default. However, for Markdoc editor support, you may wish to add the following setting in your VSCode config. This ensures authoring Markdoc files provides a Markdown-like editor experience. + +```json title=".vscode/settings.json" +"files.associations": { + "*.mdoc": "markdown" +} +``` + +## Usage + +Markdoc files can only be used within content collections. Add entries to any content collection using the `.mdoc` extension: + +```sh +src/content/docs/ + why-markdoc.mdoc + quick-start.mdoc +``` + +Then, query your collection using the [Content Collection APIs](https://docs.astro.build/en/guides/content-collections/#querying-collections): + +```astro +--- +import { getEntryBySlug } from 'astro:content'; + +const entry = await getEntryBySlug('docs', 'why-markdoc'); +const { Content } = await entry.render(); +--- + + +

{entry.data.title}

+ + +``` + +πŸ“š See the [Astro Content Collection docs][astro-content-collections] for more information. + +## Configuration + +`@astrojs/markdoc` offers configuration options to use all of Markdoc's features and connect UI components to your content. + +### Using components + +You can add Astro and UI framework components (React, Vue, Svelte, etc.) to your Markdoc using both [Markdoc tags][markdoc-tags] and HTML element [nodes][markdoc-nodes]. + +#### Render Markdoc tags as Astro components + +You may configure [Markdoc tags][markdoc-tags] that map to components. You can configure a new tag from your `astro.config` using the `tags` attribute. + + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + markdoc({ + tags: { + aside: { + render: 'Aside', + attributes: { + // Component props as attribute definitions + // See Markdoc's documentation on defining attributes + // https://markdoc.dev/docs/attributes#defining-attributes + type: { type: String }, + } + }, + }, + }), + ], +}); +``` + +Then, you can wire this render name (`'Aside'`) to a component from the `components` prop via the `` component. Note the object key name (`Aside` in this case) should match the render name: + + +```astro +--- +import { getEntryBySlug } from 'astro:content'; +import Aside from '../components/Aside.astro'; + +const entry = await getEntryBySlug('docs', 'why-markdoc'); +const { Content } = await entry.render(); +--- + + +``` + +#### Render Markdoc nodes / HTML elements as Astro components + +You may also want to map standard HTML elements like headings and paragraphs to components. For this, you can configure a custom [Markdoc node][markdoc-nodes]. This example overrides Markdoc's `heading` node to render a `Heading` component, passing the built-in `level` attribute as a prop: + +```js +// astro.config.mjs +import { defineConfig } from 'astro/config'; +import markdoc from '@astrojs/markdoc'; + +// https://astro.build/config +export default defineConfig({ + integrations: [ + markdoc({ + nodes: { + heading: { + render: 'Heading', + // Markdoc requires type defs for each attribute. + // These should mirror the `Props` type of the component + // you are rendering. + // See Markdoc's documentation on defining attributes + // https://markdoc.dev/docs/attributes#defining-attributes + attributes: { + level: { type: String }, + } + }, + }, + }), + ], +}); +``` + +Now, you can map the string passed to render (`'Heading'` in this example) to a component import. This is configured from the `` component used to render your Markdoc using the `components` prop: + +```astro +--- +import { getEntryBySlug } from 'astro:content'; +import Heading from '../components/Heading.astro'; + +const entry = await getEntryBySlug('docs', 'why-markdoc'); +const { Content } = await entry.render(); +--- + + +``` + +Now, all Markdown headings will render with the `Heading.astro` component. This example uses a level 3 heading, automatically passing `level: 3` as the component prop: + +```md +### I'm a level 3 heading! +``` + +πŸ“š [Find all of Markdoc's built-in nodes and node attributes on their documentation.](https://markdoc.dev/docs/nodes#built-in-nodes) + +#### Use client-side UI components + +Today, the `components` prop does not support the `client:` directive for hydrating components. To embed client-side components, create a wrapper `.astro` file to import your component and apply a `client:` directive manually. + +This example wraps a `Aside.tsx` component with a `ClientAside.astro` wrapper: + +```astro +--- +// src/components/ClientAside.astro +import Aside from './Aside'; +--- + +