diff options
author | 2022-05-24 17:02:11 -0500 | |
---|---|---|
committer | 2022-05-24 17:02:11 -0500 | |
commit | cfae9760b252052b6189e96398b819a4337634a8 (patch) | |
tree | e98714849c454ee4da35f788d8204aee143a52d1 | |
parent | 78e962f744a495b587bc691ad6b109543a5a5dde (diff) | |
download | astro-cfae9760b252052b6189e96398b819a4337634a8.tar.gz astro-cfae9760b252052b6189e96398b819a4337634a8.tar.zst astro-cfae9760b252052b6189e96398b819a4337634a8.zip |
Improve Markdown + Components usage (#3410)
* feat: use internal MDX tooling for markdown + components
* fix: improve MD + component tests
* chore: add changeset
* fix: make tsc happy
* fix(#3319): add regression test for component children
* fix(markdown): support HTML comments in markdown
* fix(#2474): ensure namespaced components are properly handled in markdown pages
* fix(#3220): ensure html in markdown pages does not have extra surrounding space
* fix(#3264): ensure that remark files pass in file information
* fix(#3254): enable experimentalStaticExtraction for `.md` pages
* fix: revert parsing change
* fix: remove `markdown.mode` option
31 files changed, 542 insertions, 108 deletions
diff --git a/.changeset/wicked-adults-pull.md b/.changeset/wicked-adults-pull.md new file mode 100644 index 000000000..f36d0a054 --- /dev/null +++ b/.changeset/wicked-adults-pull.md @@ -0,0 +1,6 @@ +--- +'astro': patch +'@astrojs/markdown-remark': minor +--- + +Significantally more stable behavior for "Markdown + Components" usage, which now handles component serialization much more similarly to MDX. Also supports switching between Components and Markdown without extra newlines, removes wrapping `<p>` tags from standalone components, and improves JSX expression handling. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 642fe9ffb..ada2427ad 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -515,16 +515,6 @@ export interface AstroUserConfig { /** * @docs - * @name markdown.mode - * @type {'md' | 'mdx'} - * @default `mdx` - * @description - * Control wheater to allow components inside markdown files ('mdx') or not ('md'). - */ - mode?: 'md' | 'mdx'; - - /** - * @docs * @name markdown.shikiConfig * @typeraw {Partial<ShikiConfig>} * @description diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index d75351ca3..d7c02192c 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -84,7 +84,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { const source = await fs.promises.readFile(fileId, 'utf8'); const { data: frontmatter } = matter(source); return { - code: ` + code: ` // Static export const frontmatter = ${JSON.stringify(frontmatter)}; export const file = ${JSON.stringify(fileId)}; @@ -122,12 +122,17 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { const hasInjectedScript = isPage && config._ctx.scripts.some((s) => s.stage === 'page-ssr'); // Extract special frontmatter keys - const { data: frontmatter, content: markdownContent } = matter(source); - let renderResult = await renderMarkdown(markdownContent, renderOpts); + let { data: frontmatter, content: markdownContent } = matter(source); + + // Turn HTML comments into JS comments + markdownContent = markdownContent.replace(/<\s*!--([^-->]*)(.*?)-->/gs, (whole) => `{/*${whole}*/}`) + + let renderResult = await renderMarkdown(markdownContent, { ...renderOpts, fileURL: fileUrl } as any); let { code: astroResult, metadata } = renderResult; const { layout = '', components = '', setup = '', ...content } = frontmatter; content.astro = metadata; const prelude = `--- +import { slug as $$slug } from '@astrojs/markdown-remark'; ${layout ? `import Layout from '${layout}';` : ''} ${components ? `import * from '${components}';` : ''} ${hasInjectedScript ? `import '${PAGE_SSR_SCRIPT_ID}';` : ''} @@ -151,6 +156,8 @@ ${setup}`.trim(); site: config.site ? new URL(config.base, config.site).toString() : undefined, sourcefile: id, sourcemap: 'inline', + // TODO: baseline flag + experimentalStaticExtraction: true, internalURL: `/@fs${prependForwardSlash( viteID(new URL('../runtime/server/index.js', import.meta.url)) )}`, diff --git a/packages/astro/test/astro-markdown.test.js b/packages/astro/test/astro-markdown.test.js index cfbf33b0a..0f9d28c86 100644 --- a/packages/astro/test/astro-markdown.test.js +++ b/packages/astro/test/astro-markdown.test.js @@ -28,12 +28,57 @@ describe('Astro Markdown', () => { const $ = cheerio.load(html); expect($('h2').html()).to.equal('Blog Post with JSX expressions'); - expect($('p').first().html()).to.equal('JSX at the start of the line!'); + + expect(html).to.contain('JSX at the start of the line!'); for (let listItem of ['test-1', 'test-2', 'test-3']) { - expect($(`#${listItem}`).html()).to.equal(`\n${listItem}\n`); + expect($(`#${listItem}`).html()).to.equal(`${listItem}`); } }); + it('Can handle slugs with JSX expressions in markdown pages', async () => { + const html = await fixture.readFile('/slug/index.html'); + const $ = cheerio.load(html); + + expect($('h1').attr("id")).to.equal('my-blog-post'); + }); + + it('Can handle code elements without extra spacing', async () => { + const html = await fixture.readFile('/code-element/index.html'); + const $ = cheerio.load(html); + + $('code').each((_, el) => { + expect($(el).html()).to.equal($(el).html().trim()) + }); + }); + + it('Can handle namespaced components in markdown', async () => { + const html = await fixture.readFile('/namespace/index.html'); + const $ = cheerio.load(html); + + expect($('h1').text()).to.equal('Hello Namespace!'); + expect($('button').length).to.equal(1); + }); + + it('Correctly handles component children in markdown pages (#3319)', async () => { + const html = await fixture.readFile('/children/index.html'); + + expect(html).not.to.contain('<p></p>'); + }); + + it('Can handle HTML comments in markdown pages', async () => { + const html = await fixture.readFile('/comment/index.html'); + const $ = cheerio.load(html); + + expect($('h1').text()).to.equal('It works!'); + }); + + // https://github.com/withastro/astro/issues/3254 + it('Can handle scripts in markdown pages', async () => { + const html = await fixture.readFile('/script/index.html'); + console.log(html); + expect(html).not.to.match(new RegExp("\/src\/scripts\/test\.js")); + }); + it('Can load more complex jsxy stuff', async () => { const html = await fixture.readFile('/complex/index.html'); const $ = cheerio.load(html); diff --git a/packages/astro/test/fixtures/astro-markdown/src/components/TextBlock.jsx b/packages/astro/test/fixtures/astro-markdown/src/components/TextBlock.jsx new file mode 100644 index 000000000..d9ea2534f --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/components/TextBlock.jsx @@ -0,0 +1,20 @@ +import { h } from 'preact'; + +const TextBlock = ({ + title, + children, + noPadding = false, +}) => { + return ( + <div + className={`${ + noPadding ? "" : "md:px-2 lg:px-4" + } flex-1 prose prose-headings:font-grotesk`} + > + <h3>{title}</h3> + <p>{children}</p> + </div> + ); +}; + +export default TextBlock; diff --git a/packages/astro/test/fixtures/astro-markdown/src/components/index.js b/packages/astro/test/fixtures/astro-markdown/src/components/index.js new file mode 100644 index 000000000..e7cc94c58 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/components/index.js @@ -0,0 +1,5 @@ +import Counter from './Counter'; + +export default { + Counter +} diff --git a/packages/astro/test/fixtures/astro-markdown/src/content/code-element.md b/packages/astro/test/fixtures/astro-markdown/src/content/code-element.md new file mode 100644 index 000000000..b091decc0 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/content/code-element.md @@ -0,0 +1,3 @@ +This should have `nospace` around it. + +This should have <code class="custom-class">nospace</code> around it. diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/children.md b/packages/astro/test/fixtures/astro-markdown/src/pages/children.md new file mode 100644 index 000000000..a22ee5f96 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/children.md @@ -0,0 +1,12 @@ +--- +setup: import TextBlock from '../components/TextBlock' +--- +{/* https://github.com/withastro/astro/issues/3319 */} + +<TextBlock title="Hello world!" noPadding> + <ul class="not-prose"> + <li>A</li> + <li>B</li> + <li>C</li> + </ul> +</TextBlock> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/code-element.astro b/packages/astro/test/fixtures/astro-markdown/src/pages/code-element.astro new file mode 100644 index 000000000..43ca0bfc5 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/code-element.astro @@ -0,0 +1,7 @@ +--- +const content = await Astro.glob('../content/*.md'); +--- + +<div> + {content.map(({ Content }) => <Content />)} +</div> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/comment.md b/packages/astro/test/fixtures/astro-markdown/src/pages/comment.md new file mode 100644 index 000000000..39a916351 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/comment.md @@ -0,0 +1,2 @@ +<!-- HTML comments! --> +# It works! diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/jsx-expressions.md b/packages/astro/test/fixtures/astro-markdown/src/pages/jsx-expressions.md index 2f038fdad..b87efbb2d 100644 --- a/packages/astro/test/fixtures/astro-markdown/src/pages/jsx-expressions.md +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/jsx-expressions.md @@ -8,4 +8,6 @@ list: ['test-1', 'test-2', 'test-3'] {frontmatter.paragraph} -{frontmatter.list.map(item => <p id={item}>{item}</p>)} +<ul> + {frontmatter.list.map(item => <li id={item}>{item}</li>)} +</ul> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/namespace.md b/packages/astro/test/fixtures/astro-markdown/src/pages/namespace.md new file mode 100644 index 000000000..abbe26a3b --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/namespace.md @@ -0,0 +1,7 @@ +--- +setup: import ns from '../components/index.js'; +--- + +# Hello Namespace! + +<ns.Counter>Click me!</ns.Counter> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/script.md b/packages/astro/test/fixtures/astro-markdown/src/pages/script.md new file mode 100644 index 000000000..f2b8bca88 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/script.md @@ -0,0 +1,7 @@ +# Test + +## Let's try a script... + +This should work! + +<script src="/src/scripts/test.js" /> diff --git a/packages/astro/test/fixtures/astro-markdown/src/pages/slug.md b/packages/astro/test/fixtures/astro-markdown/src/pages/slug.md new file mode 100644 index 000000000..77599b347 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/pages/slug.md @@ -0,0 +1,7 @@ +--- +title: My Blog Post +--- + +# {frontmatter.title} + +Hello world diff --git a/packages/astro/test/fixtures/astro-markdown/src/scripts/test.js b/packages/astro/test/fixtures/astro-markdown/src/scripts/test.js new file mode 100644 index 000000000..b179ee953 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown/src/scripts/test.js @@ -0,0 +1 @@ +console.log("Hello world"); diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 2868c0ffa..58be5e9d1 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -20,7 +20,8 @@ "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", "build:ci": "astro-scripts build \"src/**/*.ts\"", "postbuild": "astro-scripts copy \"src/**/*.js\"", - "dev": "astro-scripts dev \"src/**/*.ts\"" + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "mocha --exit --timeout 20000" }, "dependencies": { "@astrojs/prism": "^0.4.1", @@ -30,6 +31,7 @@ "mdast-util-mdx-jsx": "^1.2.0", "mdast-util-to-string": "^3.1.0", "micromark-extension-mdx-jsx": "^1.0.3", + "micromark-extension-mdxjs": "^1.0.0", "prismjs": "^1.28.0", "rehype-raw": "^6.1.1", "rehype-stringify": "^9.0.3", @@ -40,14 +42,19 @@ "shiki": "^0.10.1", "unified": "^10.1.2", "unist-util-map": "^3.1.1", - "unist-util-visit": "^4.1.0" + "unist-util-visit": "^4.1.0", + "vfile": "^5.3.2" }, "devDependencies": { + "@types/chai": "^4.3.1", "@types/github-slugger": "^1.3.0", "@types/hast": "^2.3.4", "@types/mdast": "^3.0.10", + "@types/mocha": "^9.1.1", "@types/prismjs": "^1.26.0", "@types/unist": "^2.0.6", - "astro-scripts": "workspace:*" + "astro-scripts": "workspace:*", + "chai": "^4.3.6", + "mocha": "^9.2.2" } } diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index bf660a508..d942bf7bf 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -2,10 +2,10 @@ import type { MarkdownRenderingOptions, MarkdownRenderingResult } from './types' import createCollectHeaders from './rehype-collect-headers.js'; import scopedStyles from './remark-scoped-styles.js'; -import { remarkExpressions, loadRemarkExpressions } from './remark-expressions.js'; import rehypeExpressions from './rehype-expressions.js'; import rehypeIslands from './rehype-islands.js'; -import { remarkJsx, loadRemarkJsx } from './remark-jsx.js'; +import remarkMdxish from './remark-mdxish.js'; +import remarkMarkAndUnravel from './remark-mark-and-unravel.js'; import rehypeJsx from './rehype-jsx.js'; import rehypeEscape from './rehype-escape.js'; import remarkPrism from './remark-prism.js'; @@ -18,27 +18,33 @@ import markdown from 'remark-parse'; import markdownToHtml from 'remark-rehype'; import rehypeStringify from 'rehype-stringify'; import rehypeRaw from 'rehype-raw'; +import Slugger from 'github-slugger'; +import { VFile } from 'vfile'; export * from './types.js'; export const DEFAULT_REMARK_PLUGINS = ['remark-gfm', 'remark-smartypants']; export const DEFAULT_REHYPE_PLUGINS = []; +const slugger = new Slugger(); +export function slug(value: string): string { + return slugger.slug(value); +} + /** Shared utility for rendering markdown */ export async function renderMarkdown( content: string, - opts: MarkdownRenderingOptions + opts: MarkdownRenderingOptions = {} ): Promise<MarkdownRenderingResult> { - let { mode, syntaxHighlight, shikiConfig, remarkPlugins, rehypePlugins } = opts; + let { fileURL, mode = 'mdx', syntaxHighlight = 'shiki', shikiConfig = {}, remarkPlugins = [], rehypePlugins = [] } = opts; + const input = new VFile({ value: content, path: fileURL }) const scopedClassName = opts.$?.scopedClassName; const isMDX = mode === 'mdx'; const { headers, rehypeCollectHeaders } = createCollectHeaders(); - await Promise.all([loadRemarkExpressions(), loadRemarkJsx()]); // Vite bug: dynamically import() these because of CJS interop (this will cache) - let parser = unified() .use(markdown) - .use(isMDX ? [remarkJsx, remarkExpressions] : []) + .use(isMDX ? [remarkMdxish, remarkMarkAndUnravel] : []) .use([remarkUnwrap]); if (remarkPlugins.length === 0 && rehypePlugins.length === 0) { @@ -68,7 +74,13 @@ export async function renderMarkdown( markdownToHtml as any, { allowDangerousHtml: true, - passThrough: ['raw', 'mdxTextExpression', 'mdxJsxTextElement', 'mdxJsxFlowElement'], + passThrough: [ + 'raw', + 'mdxFlowExpression', + 'mdxJsxFlowElement', + 'mdxJsxTextElement', + 'mdxTextExpression', + ], }, ], ]); @@ -87,7 +99,7 @@ export async function renderMarkdown( const vfile = await parser .use([rehypeCollectHeaders]) .use(rehypeStringify, { allowDangerousHtml: true }) - .process(content); + .process(input); result = vfile.toString(); } catch (err) { console.error(err); diff --git a/packages/markdown/remark/src/mdast-util-mdxish.ts b/packages/markdown/remark/src/mdast-util-mdxish.ts new file mode 100644 index 000000000..52a99deeb --- /dev/null +++ b/packages/markdown/remark/src/mdast-util-mdxish.ts @@ -0,0 +1,18 @@ +import { + mdxExpressionFromMarkdown, + mdxExpressionToMarkdown +} from 'mdast-util-mdx-expression' +import {mdxJsxFromMarkdown, mdxJsxToMarkdown} from 'mdast-util-mdx-jsx' + +export function mdxFromMarkdown(): any { + return [mdxExpressionFromMarkdown, mdxJsxFromMarkdown] +} + +export function mdxToMarkdown(): any { + return { + extensions: [ + mdxExpressionToMarkdown, + mdxJsxToMarkdown, + ] + } +} diff --git a/packages/markdown/remark/src/rehype-collect-headers.ts b/packages/markdown/remark/src/rehype-collect-headers.ts index 927f96590..77126ab7e 100644 --- a/packages/markdown/remark/src/rehype-collect-headers.ts +++ b/packages/markdown/remark/src/rehype-collect-headers.ts @@ -17,15 +17,38 @@ export default function createCollectHeaders() { if (!level) return; const depth = Number.parseInt(level); + let raw = ''; let text = ''; - - visit(node, 'text', (child) => { - text += child.value; + let isJSX = false; + visit(node, (child) => { + if (child.type === 'element') { + return; + } + if (child.type === 'raw') { + // HACK: serialized JSX from internal plugins, ignore these for slug + if (child.value.startsWith('\n<') || child.value.endsWith('>\n')) { + raw += child.value.replace(/^\n|\n$/g, ''); + return; + } + } + if (child.type === 'text' || child.type === 'raw') { + raw += child.value; + text += child.value; + isJSX = isJSX || child.value.includes('{'); + } }); + node.properties = node.properties || {}; if (typeof node.properties.id !== 'string') { - node.properties.id = slugger.slug(text); + if (isJSX) { + // HACK: for ids that have JSX content, use $$slug helper to generate slug at runtime + node.properties.id = `$$slug(\`${text.replace(/\{/g, '${')}\`)`; + (node as any).type = 'raw'; + (node as any).value = `<${node.tagName} id={${node.properties.id}}>${raw}</${node.tagName}>`; + } else { + node.properties.id = slugger.slug(text); + } } headers.push({ depth, slug: node.properties.id, text }); diff --git a/packages/markdown/remark/src/rehype-expressions.ts b/packages/markdown/remark/src/rehype-expressions.ts index 26d04623d..f06f242e2 100644 --- a/packages/markdown/remark/src/rehype-expressions.ts +++ b/packages/markdown/remark/src/rehype-expressions.ts @@ -3,8 +3,14 @@ import { map } from 'unist-util-map'; export default function rehypeExpressions(): any { return function (node: any): any { return map(node, (child) => { + if (child.type === 'text') { + return { ...child, type: 'raw' }; + } if (child.type === 'mdxTextExpression') { - return { type: 'text', value: `{${(child as any).value}}` }; + return { type: 'raw', value: `{${(child as any).value}}` }; + } + if (child.type === 'mdxFlowExpression') { + return { type: 'raw', value: `{${(child as any).value}}` }; } return child; }); diff --git a/packages/markdown/remark/src/rehype-jsx.ts b/packages/markdown/remark/src/rehype-jsx.ts index cccbd5548..62eb977c0 100644 --- a/packages/markdown/remark/src/rehype-jsx.ts +++ b/packages/markdown/remark/src/rehype-jsx.ts @@ -8,19 +8,41 @@ export default function rehypeJsx(): any { return { ...child, tagName: `${child.tagName}` }; } if (MDX_ELEMENTS.has(child.type)) { - return { - ...child, - type: 'element', - tagName: `${child.name}`, - properties: child.attributes.reduce((acc: any[], entry: any) => { + const attrs = child.attributes.reduce((acc: any[], entry: any) => { let attr = entry.value; if (attr && typeof attr === 'object') { attr = `{${attr.value}}`; + } else if (attr && entry.type === 'mdxJsxExpressionAttribute') { + attr = `{${attr}}` } else if (attr === null) { - attr = `{true}`; + attr = ""; + } else if (typeof attr === 'string') { + attr = `"${attr}"`; + } + if (!entry.name) { + return acc + ` ${attr}`; } - return Object.assign(acc, { [entry.name]: attr }); - }, {}), + return acc + ` ${entry.name}${attr ? '=' : ''}${attr}`; + }, ''); + + if (child.children.length === 0) { + return { + type: 'raw', + value: `<${child.name}${attrs} />` + }; + } + child.children.splice(0, 0, { + type: 'raw', + value: `\n<${child.name}${attrs}>` + }) + child.children.push({ + type: 'raw', + value: `</${child.name}>\n` + }) + return { + ...child, + type: 'element', + tagName: `Fragment`, }; } return child; diff --git a/packages/markdown/remark/src/remark-expressions.ts b/packages/markdown/remark/src/remark-expressions.ts deleted file mode 100644 index 8e7af19f3..000000000 --- a/packages/markdown/remark/src/remark-expressions.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Vite bug: dynamically import() modules needed for CJS. Cache in memory to keep side effects -let mdxExpressionFromMarkdown: any; -let mdxExpressionToMarkdown: any; - -export function remarkExpressions(this: any, options: any) { - let settings = options || {}; - let data = this.data(); - - add('fromMarkdownExtensions', mdxExpressionFromMarkdown); - add('toMarkdownExtensions', mdxExpressionToMarkdown); - - function add(field: any, value: any) { - /* istanbul ignore if - other extensions. */ - if (data[field]) data[field].push(value); - else data[field] = [value]; - } -} - -export async function loadRemarkExpressions() { - if (!mdxExpressionFromMarkdown || !mdxExpressionToMarkdown) { - const mdastUtilMdxExpression = await import('mdast-util-mdx-expression'); - mdxExpressionFromMarkdown = mdastUtilMdxExpression.mdxExpressionFromMarkdown; - mdxExpressionToMarkdown = mdastUtilMdxExpression.mdxExpressionToMarkdown; - } -} diff --git a/packages/markdown/remark/src/remark-jsx.ts b/packages/markdown/remark/src/remark-jsx.ts deleted file mode 100644 index 637bac9ee..000000000 --- a/packages/markdown/remark/src/remark-jsx.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Vite bug: dynamically import() modules needed for CJS. Cache in memory to keep side effects -let mdxJsx: any; -let mdxJsxFromMarkdown: any; -let mdxJsxToMarkdown: any; - -export function remarkJsx(this: any, options: any) { - let settings = options || {}; - let data = this.data(); - - // TODO this seems to break adding slugs, no idea why add('micromarkExtensions', mdxJsx({})); - add('fromMarkdownExtensions', mdxJsxFromMarkdown); - add('toMarkdownExtensions', mdxJsxToMarkdown); - - function add(field: any, value: any) { - /* istanbul ignore if - other extensions. */ - if (data[field]) data[field].push(value); - else data[field] = [value]; - } -} - -export async function loadRemarkJsx() { - if (!mdxJsx) { - const micromarkMdxJsx = await import('micromark-extension-mdx-jsx'); - mdxJsx = micromarkMdxJsx.mdxJsx; - } - if (!mdxJsxFromMarkdown || !mdxJsxToMarkdown) { - const mdastUtilMdxJsx = await import('mdast-util-mdx-jsx'); - mdxJsxFromMarkdown = mdastUtilMdxJsx.mdxJsxFromMarkdown; - mdxJsxToMarkdown = mdastUtilMdxJsx.mdxJsxToMarkdown; - } -} diff --git a/packages/markdown/remark/src/remark-mark-and-unravel.ts b/packages/markdown/remark/src/remark-mark-and-unravel.ts new file mode 100644 index 000000000..4490e4a93 --- /dev/null +++ b/packages/markdown/remark/src/remark-mark-and-unravel.ts @@ -0,0 +1,81 @@ +// https://github.com/mdx-js/mdx/blob/main/packages/mdx/lib/plugin/remark-mark-and-unravel.js +/** + * @typedef {import('mdast').Root} Root + * @typedef {import('mdast').Content} Content + * @typedef {Root|Content} Node + * @typedef {Extract<Node, import('unist').Parent>} Parent + * + * @typedef {import('remark-mdx')} DoNotTouchAsThisImportItIncludesMdxInTree + */ + +import {visit} from 'unist-util-visit' + +/** + * A tiny plugin that unravels `<p><h1>x</h1></p>` but also + * `<p><Component /></p>` (so it has no knowledge of “HTML”). + * It also marks JSX as being explicitly JSX, so when a user passes a `h1` + * component, it is used for `# heading` but not for `<h1>heading</h1>`. + * + * @type {import('unified').Plugin<Array<void>, Root>} + */ +export default function remarkMarkAndUnravel() { + return (tree: any) => { + visit(tree, (node, index, parent_) => { + const parent = /** @type {Parent} */ (parent_) + let offset = -1 + let all = true + /** @type {boolean|undefined} */ + let oneOrMore + + if (parent && typeof index === 'number' && node.type === 'paragraph') { + const children = node.children + + while (++offset < children.length) { + const child = children[offset] + + if ( + child.type === 'mdxJsxTextElement' || + child.type === 'mdxTextExpression' + ) { + oneOrMore = true + } else if ( + child.type === 'text' && + /^[\t\r\n ]+$/.test(String(child.value)) + ) { + // Empty. + } else { + all = false + break + } + } + + if (all && oneOrMore) { + offset = -1 + + while (++offset < children.length) { + const child = children[offset] + + if (child.type === 'mdxJsxTextElement') { + child.type = 'mdxJsxFlowElement' + } + + if (child.type === 'mdxTextExpression') { + child.type = 'mdxFlowExpression' + } + } + + parent.children.splice(index, 1, ...children) + return index + } + } + + if ( + node.type === 'mdxJsxFlowElement' || + node.type === 'mdxJsxTextElement' + ) { + const data = node.data || (node.data = {}) + data._mdxExplicitJsx = true + } + }) + } +} diff --git a/packages/markdown/remark/src/remark-mdxish.ts b/packages/markdown/remark/src/remark-mdxish.ts new file mode 100644 index 000000000..b5d41d228 --- /dev/null +++ b/packages/markdown/remark/src/remark-mdxish.ts @@ -0,0 +1,15 @@ +import {mdxjs} from 'micromark-extension-mdxjs' +import { mdxFromMarkdown, mdxToMarkdown } from './mdast-util-mdxish.js' + +export default function remarkMdxish(this: any, options = {}) { + const data = this.data() + + add('micromarkExtensions', mdxjs(options)) + add('fromMarkdownExtensions', mdxFromMarkdown()) + add('toMarkdownExtensions', mdxToMarkdown()) + + function add(field: string, value: unknown) { + const list = data[field] ? data[field] : (data[field] = []) + list.push(value) + } +} diff --git a/packages/markdown/remark/src/remark-shiki.ts b/packages/markdown/remark/src/remark-shiki.ts index e00156cb5..0b51f07ff 100644 --- a/packages/markdown/remark/src/remark-shiki.ts +++ b/packages/markdown/remark/src/remark-shiki.ts @@ -11,7 +11,7 @@ import type { ShikiConfig } from './types.js'; const highlighterCacheAsync = new Map<string, Promise<shiki.Highlighter>>(); const remarkShiki = async ( - { langs, theme, wrap }: ShikiConfig, + { langs = [], theme = 'github-dark', wrap = false }: ShikiConfig, scopedClassName?: string | null ) => { const cacheID: string = typeof theme === 'string' ? theme : theme.name; diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index 3aef31710..af9778c9a 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -20,22 +20,24 @@ export type RehypePlugin<PluginParameters extends any[] = any[]> = unified.Plugi export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlugin, any])[]; export interface ShikiConfig { - langs: ILanguageRegistration[]; - theme: Theme | IThemeRegistration; - wrap: boolean | null; + langs?: ILanguageRegistration[]; + theme?: Theme | IThemeRegistration; + wrap?: boolean | null; } export interface AstroMarkdownOptions { - mode: 'md' | 'mdx'; - drafts: boolean; - syntaxHighlight: 'shiki' | 'prism' | false; - shikiConfig: ShikiConfig; - remarkPlugins: RemarkPlugins; - rehypePlugins: RehypePlugins; + mode?: 'md' | 'mdx'; + drafts?: boolean; + syntaxHighlight?: 'shiki' | 'prism' | false; + shikiConfig?: ShikiConfig; + remarkPlugins?: RemarkPlugins; + rehypePlugins?: RehypePlugins; } export interface MarkdownRenderingOptions extends AstroMarkdownOptions { /** @internal */ + fileURL?: URL; + /** @internal */ $?: { scopedClassName: string | null; }; diff --git a/packages/markdown/remark/test/components.test.js b/packages/markdown/remark/test/components.test.js new file mode 100644 index 000000000..0f3418c24 --- /dev/null +++ b/packages/markdown/remark/test/components.test.js @@ -0,0 +1,62 @@ +import { renderMarkdown } from '../dist/index.js'; +import chai from 'chai'; + +describe('components', () => { + it('should be able to serialize string', async () => { + const { code } = await renderMarkdown(`<Component str="cool!" />`, {}); + + chai.expect(code).to.equal(`<Component str="cool!" />`); + }); + + it('should be able to serialize boolean attribute', async () => { + const { code } = await renderMarkdown(`<Component bool={true} />`, {}); + + chai.expect(code).to.equal(`<Component bool={true} />`); + }); + + it('should be able to serialize array', async () => { + const { code } = await renderMarkdown(`<Component prop={["a", "b", "c"]} />`, {}); + + chai.expect(code).to.equal(`<Component prop={["a", "b", "c"]} />`); + }); + + it('should be able to serialize object', async () => { + const { code } = await renderMarkdown(`<Component prop={{ a: 0, b: 1, c: 2 }} />`, {}); + + chai.expect(code).to.equal(`<Component prop={{ a: 0, b: 1, c: 2 }} />`); + }); + + it('should be able to serialize empty attribute', async () => { + const { code } = await renderMarkdown(`<Component empty />`, {}); + + chai.expect(code).to.equal(`<Component empty />`); + }); + + // Notable omission: shorthand attribute + + it('should be able to serialize spread attribute', async () => { + const { code } = await renderMarkdown(`<Component {...spread} />`, {}); + + chai.expect(code).to.equal(`<Component {...spread} />`); + }); + + it('should allow client:* directives', async () => { + const { code } = await renderMarkdown(`<Component client:load />`, {}); + + chai.expect(code).to.equal(`<Component client:load />`); + }); + + it('should normalize children', async () => { + const { code } = await renderMarkdown(`<Component bool={true}>Hello world!</Component>`, {}); + + chai.expect(code).to.equal(`<Fragment>\n<Component bool={true}>Hello world!</Component>\n</Fragment>`); + }); + + it('should allow markdown without many spaces', async () => { + const { code } = await renderMarkdown(`<Component> +# Hello world! +</Component>`, {}); + + chai.expect(code).to.equal(`<Fragment>\n<Component><h1 id="hello-world">Hello world!</h1></Component>\n</Fragment>`); + }); +}); diff --git a/packages/markdown/remark/test/expressions.test.js b/packages/markdown/remark/test/expressions.test.js new file mode 100644 index 000000000..bcc95cbed --- /dev/null +++ b/packages/markdown/remark/test/expressions.test.js @@ -0,0 +1,40 @@ +import { renderMarkdown } from '../dist/index.js'; +import chai from 'chai'; + +describe('expressions', () => { + it('should be able to serialize bare expession', async () => { + const { code } = await renderMarkdown(`{a}`, {}); + + chai.expect(code).to.equal(`{a}`); + }); + + it('should be able to serialize expression inside component', async () => { + const { code } = await renderMarkdown(`<Component>{a}</Component>`, {}); + + chai.expect(code).to.equal(`<Fragment>\n<Component>{a}</Component>\n</Fragment>`); + }); + + it('should be able to serialize expression inside markdown', async () => { + const { code } = await renderMarkdown(`# {frontmatter.title}`, {}); + + chai.expect(code).to.equal(`<h1 id={$$slug(\`\${frontmatter.title}\`)}>{frontmatter.title}</h1>`); + }); + + it('should be able to serialize complex expression inside markdown', async () => { + const { code } = await renderMarkdown(`# Hello {frontmatter.name}`, {}); + + chai.expect(code).to.equal(`<h1 id={$$slug(\`Hello \${frontmatter.name}\`)}>Hello {frontmatter.name}</h1>`); + }); + + it('should be able to serialize complex expression with markup inside markdown', async () => { + const { code } = await renderMarkdown(`# Hello <span>{frontmatter.name}</span>`, {}); + + chai.expect(code).to.equal(`<h1 id={$$slug(\`Hello \${frontmatter.name}\`)}>Hello <span>{frontmatter.name}</span></h1>`); + }); + + it('should be able to serialize function expression', async () => { + const { code } = await renderMarkdown(`{frontmatter.list.map(item => <p id={item}>{item}</p>)}` , {}); + + chai.expect(code).to.equal(`{frontmatter.list.map(item => <p id={item}>{item}</p>)}`); + }) +}); diff --git a/packages/markdown/remark/test/plugins.test.js b/packages/markdown/remark/test/plugins.test.js new file mode 100644 index 000000000..4954047b5 --- /dev/null +++ b/packages/markdown/remark/test/plugins.test.js @@ -0,0 +1,26 @@ +import { renderMarkdown } from '../dist/index.js'; +import chai from 'chai'; + +import { fileURLToPath } from 'node:url'; + +describe('plugins', () => { + // https://github.com/withastro/astro/issues/3264 + it('should be able to get file path when passing fileURL', async () => { + let context; + await renderMarkdown(`test`, { + fileURL: new URL('virtual.md', import.meta.url), + remarkPlugins: [ + function () { + const transformer = (tree, file) => { + context = file; + }; + + return transformer; + } + ] + }); + + chai.expect(typeof context).to.equal('object'); + chai.expect(context.path).to.equal(fileURLToPath(new URL('virtual.md', import.meta.url))); + }); +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11d4a719e..a2b98c3d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1690,18 +1690,23 @@ importers: packages/markdown/remark: specifiers: '@astrojs/prism': ^0.4.1 + '@types/chai': ^4.3.1 '@types/github-slugger': ^1.3.0 '@types/hast': ^2.3.4 '@types/mdast': ^3.0.10 + '@types/mocha': ^9.1.1 '@types/prismjs': ^1.26.0 '@types/unist': ^2.0.6 assert: ^2.0.0 astro-scripts: workspace:* + chai: ^4.3.6 github-slugger: ^1.4.0 mdast-util-mdx-expression: ^1.2.0 mdast-util-mdx-jsx: ^1.2.0 mdast-util-to-string: ^3.1.0 micromark-extension-mdx-jsx: ^1.0.3 + micromark-extension-mdxjs: ^1.0.0 + mocha: ^9.2.2 prismjs: ^1.28.0 rehype-raw: ^6.1.1 rehype-stringify: ^9.0.3 @@ -1713,6 +1718,7 @@ importers: unified: ^10.1.2 unist-util-map: ^3.1.1 unist-util-visit: ^4.1.0 + vfile: ^5.3.2 dependencies: '@astrojs/prism': link:../../astro-prism assert: 2.0.0 @@ -1721,6 +1727,7 @@ importers: mdast-util-mdx-jsx: 1.2.0 mdast-util-to-string: 3.1.0 micromark-extension-mdx-jsx: 1.0.3 + micromark-extension-mdxjs: 1.0.0 prismjs: 1.28.0 rehype-raw: 6.1.1 rehype-stringify: 9.0.3 @@ -1732,13 +1739,18 @@ importers: unified: 10.1.2 unist-util-map: 3.1.1 unist-util-visit: 4.1.0 + vfile: 5.3.2 devDependencies: + '@types/chai': 4.3.1 '@types/github-slugger': 1.3.0 '@types/hast': 2.3.4 '@types/mdast': 3.0.10 + '@types/mocha': 9.1.1 '@types/prismjs': 1.26.0 '@types/unist': 2.0.6 astro-scripts: link:../../../scripts + chai: 4.3.6 + mocha: 9.2.2 packages/telemetry: specifiers: @@ -6976,7 +6988,6 @@ packages: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: acorn: 8.7.1 - dev: true /acorn-node/1.8.2: resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} @@ -10343,6 +10354,18 @@ packages: micromark-util-types: 1.0.2 dev: false + /micromark-extension-mdx-expression/1.0.3: + resolution: {integrity: sha512-TjYtjEMszWze51NJCZmhv7MEBcgYRgb3tJeMAJ+HQCAaZHHRBaDCccqQzGizR/H4ODefP44wRTgOn2vE5I6nZA==} + dependencies: + micromark-factory-mdx-expression: 1.0.6 + micromark-factory-space: 1.0.0 + micromark-util-character: 1.1.0 + micromark-util-events-to-acorn: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + uvu: 0.5.3 + dev: false + /micromark-extension-mdx-jsx/1.0.3: resolution: {integrity: sha512-VfA369RdqUISF0qGgv2FfV7gGjHDfn9+Qfiv5hEwpyr1xscRj/CiVRkU7rywGFCO7JwJ5L0e7CJz60lY52+qOA==} dependencies: @@ -10357,6 +10380,38 @@ packages: vfile-message: 3.1.2 dev: false + /micromark-extension-mdx-md/1.0.0: + resolution: {integrity: sha512-xaRAMoSkKdqZXDAoSgp20Azm0aRQKGOl0RrS81yGu8Hr/JhMsBmfs4wR7m9kgVUIO36cMUQjNyiyDKPrsv8gOw==} + dependencies: + micromark-util-types: 1.0.2 + dev: false + + /micromark-extension-mdxjs-esm/1.0.3: + resolution: {integrity: sha512-2N13ol4KMoxb85rdDwTAC6uzs8lMX0zeqpcyx7FhS7PxXomOnLactu8WI8iBNXW8AVyea3KIJd/1CKnUmwrK9A==} + dependencies: + micromark-core-commonmark: 1.0.6 + micromark-util-character: 1.1.0 + micromark-util-events-to-acorn: 1.1.0 + micromark-util-symbol: 1.0.1 + micromark-util-types: 1.0.2 + unist-util-position-from-estree: 1.1.1 + uvu: 0.5.3 + vfile-message: 3.1.2 + dev: false + + /micromark-extension-mdxjs/1.0.0: + resolution: {integrity: sha512-TZZRZgeHvtgm+IhtgC2+uDMR7h8eTKF0QUX9YsgoL9+bADBpBY6SiLvWqnBlLbCEevITmTqmEuY3FoxMKVs1rQ==} + dependencies: + acorn: 8.7.1 + acorn-jsx: 5.3.2_acorn@8.7.1 + micromark-extension-mdx-expression: 1.0.3 + micromark-extension-mdx-jsx: 1.0.3 + micromark-extension-mdx-md: 1.0.0 + micromark-extension-mdxjs-esm: 1.0.3 + micromark-util-combine-extensions: 1.0.0 + micromark-util-types: 1.0.2 + dev: false + /micromark-factory-destination/1.0.0: resolution: {integrity: sha512-eUBA7Rs1/xtTVun9TmV3gjfPz2wEwgK5R5xcbIM5ZYAtvGF6JkyaDsj0agx8urXnO31tEO6Ug83iVH3tdedLnw==} dependencies: |