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
This commit is contained in:
Nate Moore 2022-05-24 17:02:11 -05:00 committed by GitHub
parent 78e962f744
commit cfae9760b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 542 additions and 108 deletions

View file

@ -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.

View file

@ -513,16 +513,6 @@ export interface AstroUserConfig {
*/ */
drafts?: boolean; drafts?: boolean;
/**
* @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 * @docs
* @name markdown.shikiConfig * @name markdown.shikiConfig

View file

@ -84,7 +84,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
const source = await fs.promises.readFile(fileId, 'utf8'); const source = await fs.promises.readFile(fileId, 'utf8');
const { data: frontmatter } = matter(source); const { data: frontmatter } = matter(source);
return { return {
code: ` code: `
// Static // Static
export const frontmatter = ${JSON.stringify(frontmatter)}; export const frontmatter = ${JSON.stringify(frontmatter)};
export const file = ${JSON.stringify(fileId)}; 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'); const hasInjectedScript = isPage && config._ctx.scripts.some((s) => s.stage === 'page-ssr');
// Extract special frontmatter keys // Extract special frontmatter keys
const { data: frontmatter, content: markdownContent } = matter(source); let { data: frontmatter, content: markdownContent } = matter(source);
let renderResult = await renderMarkdown(markdownContent, renderOpts);
// 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; let { code: astroResult, metadata } = renderResult;
const { layout = '', components = '', setup = '', ...content } = frontmatter; const { layout = '', components = '', setup = '', ...content } = frontmatter;
content.astro = metadata; content.astro = metadata;
const prelude = `--- const prelude = `---
import { slug as $$slug } from '@astrojs/markdown-remark';
${layout ? `import Layout from '${layout}';` : ''} ${layout ? `import Layout from '${layout}';` : ''}
${components ? `import * from '${components}';` : ''} ${components ? `import * from '${components}';` : ''}
${hasInjectedScript ? `import '${PAGE_SSR_SCRIPT_ID}';` : ''} ${hasInjectedScript ? `import '${PAGE_SSR_SCRIPT_ID}';` : ''}
@ -151,6 +156,8 @@ ${setup}`.trim();
site: config.site ? new URL(config.base, config.site).toString() : undefined, site: config.site ? new URL(config.base, config.site).toString() : undefined,
sourcefile: id, sourcefile: id,
sourcemap: 'inline', sourcemap: 'inline',
// TODO: baseline flag
experimentalStaticExtraction: true,
internalURL: `/@fs${prependForwardSlash( internalURL: `/@fs${prependForwardSlash(
viteID(new URL('../runtime/server/index.js', import.meta.url)) viteID(new URL('../runtime/server/index.js', import.meta.url))
)}`, )}`,

View file

@ -28,12 +28,57 @@ describe('Astro Markdown', () => {
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('h2').html()).to.equal('Blog Post with JSX expressions'); 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']) { 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 () => { it('Can load more complex jsxy stuff', async () => {
const html = await fixture.readFile('/complex/index.html'); const html = await fixture.readFile('/complex/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);

View file

@ -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;

View file

@ -0,0 +1,5 @@
import Counter from './Counter';
export default {
Counter
}

View file

@ -0,0 +1,3 @@
This should have `nospace` around it.
This should have <code class="custom-class">nospace</code> around it.

View file

@ -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>

View file

@ -0,0 +1,7 @@
---
const content = await Astro.glob('../content/*.md');
---
<div>
{content.map(({ Content }) => <Content />)}
</div>

View file

@ -0,0 +1,2 @@
<!-- HTML comments! -->
# It works!

View file

@ -8,4 +8,6 @@ list: ['test-1', 'test-2', 'test-3']
{frontmatter.paragraph} {frontmatter.paragraph}
{frontmatter.list.map(item => <p id={item}>{item}</p>)} <ul>
{frontmatter.list.map(item => <li id={item}>{item}</li>)}
</ul>

View file

@ -0,0 +1,7 @@
---
setup: import ns from '../components/index.js';
---
# Hello Namespace!
<ns.Counter>Click me!</ns.Counter>

View file

@ -0,0 +1,7 @@
# Test
## Let's try a script...
This should work!
<script src="/src/scripts/test.js" />

View file

@ -0,0 +1,7 @@
---
title: My Blog Post
---
# {frontmatter.title}
Hello world

View file

@ -0,0 +1 @@
console.log("Hello world");

View file

@ -20,7 +20,8 @@
"build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json", "build": "astro-scripts build \"src/**/*.ts\" && tsc -p tsconfig.json",
"build:ci": "astro-scripts build \"src/**/*.ts\"", "build:ci": "astro-scripts build \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.js\"", "postbuild": "astro-scripts copy \"src/**/*.js\"",
"dev": "astro-scripts dev \"src/**/*.ts\"" "dev": "astro-scripts dev \"src/**/*.ts\"",
"test": "mocha --exit --timeout 20000"
}, },
"dependencies": { "dependencies": {
"@astrojs/prism": "^0.4.1", "@astrojs/prism": "^0.4.1",
@ -30,6 +31,7 @@
"mdast-util-mdx-jsx": "^1.2.0", "mdast-util-mdx-jsx": "^1.2.0",
"mdast-util-to-string": "^3.1.0", "mdast-util-to-string": "^3.1.0",
"micromark-extension-mdx-jsx": "^1.0.3", "micromark-extension-mdx-jsx": "^1.0.3",
"micromark-extension-mdxjs": "^1.0.0",
"prismjs": "^1.28.0", "prismjs": "^1.28.0",
"rehype-raw": "^6.1.1", "rehype-raw": "^6.1.1",
"rehype-stringify": "^9.0.3", "rehype-stringify": "^9.0.3",
@ -40,14 +42,19 @@
"shiki": "^0.10.1", "shiki": "^0.10.1",
"unified": "^10.1.2", "unified": "^10.1.2",
"unist-util-map": "^3.1.1", "unist-util-map": "^3.1.1",
"unist-util-visit": "^4.1.0" "unist-util-visit": "^4.1.0",
"vfile": "^5.3.2"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.3.1",
"@types/github-slugger": "^1.3.0", "@types/github-slugger": "^1.3.0",
"@types/hast": "^2.3.4", "@types/hast": "^2.3.4",
"@types/mdast": "^3.0.10", "@types/mdast": "^3.0.10",
"@types/mocha": "^9.1.1",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.0",
"@types/unist": "^2.0.6", "@types/unist": "^2.0.6",
"astro-scripts": "workspace:*" "astro-scripts": "workspace:*",
"chai": "^4.3.6",
"mocha": "^9.2.2"
} }
} }

View file

@ -2,10 +2,10 @@ import type { MarkdownRenderingOptions, MarkdownRenderingResult } from './types'
import createCollectHeaders from './rehype-collect-headers.js'; import createCollectHeaders from './rehype-collect-headers.js';
import scopedStyles from './remark-scoped-styles.js'; import scopedStyles from './remark-scoped-styles.js';
import { remarkExpressions, loadRemarkExpressions } from './remark-expressions.js';
import rehypeExpressions from './rehype-expressions.js'; import rehypeExpressions from './rehype-expressions.js';
import rehypeIslands from './rehype-islands.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 rehypeJsx from './rehype-jsx.js';
import rehypeEscape from './rehype-escape.js'; import rehypeEscape from './rehype-escape.js';
import remarkPrism from './remark-prism.js'; import remarkPrism from './remark-prism.js';
@ -18,27 +18,33 @@ import markdown from 'remark-parse';
import markdownToHtml from 'remark-rehype'; import markdownToHtml from 'remark-rehype';
import rehypeStringify from 'rehype-stringify'; import rehypeStringify from 'rehype-stringify';
import rehypeRaw from 'rehype-raw'; import rehypeRaw from 'rehype-raw';
import Slugger from 'github-slugger';
import { VFile } from 'vfile';
export * from './types.js'; export * from './types.js';
export const DEFAULT_REMARK_PLUGINS = ['remark-gfm', 'remark-smartypants']; export const DEFAULT_REMARK_PLUGINS = ['remark-gfm', 'remark-smartypants'];
export const DEFAULT_REHYPE_PLUGINS = []; export const DEFAULT_REHYPE_PLUGINS = [];
const slugger = new Slugger();
export function slug(value: string): string {
return slugger.slug(value);
}
/** Shared utility for rendering markdown */ /** Shared utility for rendering markdown */
export async function renderMarkdown( export async function renderMarkdown(
content: string, content: string,
opts: MarkdownRenderingOptions opts: MarkdownRenderingOptions = {}
): Promise<MarkdownRenderingResult> { ): 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 scopedClassName = opts.$?.scopedClassName;
const isMDX = mode === 'mdx'; const isMDX = mode === 'mdx';
const { headers, rehypeCollectHeaders } = createCollectHeaders(); const { headers, rehypeCollectHeaders } = createCollectHeaders();
await Promise.all([loadRemarkExpressions(), loadRemarkJsx()]); // Vite bug: dynamically import() these because of CJS interop (this will cache)
let parser = unified() let parser = unified()
.use(markdown) .use(markdown)
.use(isMDX ? [remarkJsx, remarkExpressions] : []) .use(isMDX ? [remarkMdxish, remarkMarkAndUnravel] : [])
.use([remarkUnwrap]); .use([remarkUnwrap]);
if (remarkPlugins.length === 0 && rehypePlugins.length === 0) { if (remarkPlugins.length === 0 && rehypePlugins.length === 0) {
@ -68,7 +74,13 @@ export async function renderMarkdown(
markdownToHtml as any, markdownToHtml as any,
{ {
allowDangerousHtml: true, 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 const vfile = await parser
.use([rehypeCollectHeaders]) .use([rehypeCollectHeaders])
.use(rehypeStringify, { allowDangerousHtml: true }) .use(rehypeStringify, { allowDangerousHtml: true })
.process(content); .process(input);
result = vfile.toString(); result = vfile.toString();
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View file

@ -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,
]
}
}

View file

@ -17,15 +17,38 @@ export default function createCollectHeaders() {
if (!level) return; if (!level) return;
const depth = Number.parseInt(level); const depth = Number.parseInt(level);
let raw = '';
let text = ''; let text = '';
let isJSX = false;
visit(node, 'text', (child) => { visit(node, (child) => {
text += child.value; 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 || {}; node.properties = node.properties || {};
if (typeof node.properties.id !== 'string') { 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 }); headers.push({ depth, slug: node.properties.id, text });

View file

@ -3,8 +3,14 @@ import { map } from 'unist-util-map';
export default function rehypeExpressions(): any { export default function rehypeExpressions(): any {
return function (node: any): any { return function (node: any): any {
return map(node, (child) => { return map(node, (child) => {
if (child.type === 'text') {
return { ...child, type: 'raw' };
}
if (child.type === 'mdxTextExpression') { 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; return child;
}); });

View file

@ -8,19 +8,41 @@ export default function rehypeJsx(): any {
return { ...child, tagName: `${child.tagName}` }; return { ...child, tagName: `${child.tagName}` };
} }
if (MDX_ELEMENTS.has(child.type)) { if (MDX_ELEMENTS.has(child.type)) {
return { const attrs = child.attributes.reduce((acc: any[], entry: any) => {
...child,
type: 'element',
tagName: `${child.name}`,
properties: child.attributes.reduce((acc: any[], entry: any) => {
let attr = entry.value; let attr = entry.value;
if (attr && typeof attr === 'object') { if (attr && typeof attr === 'object') {
attr = `{${attr.value}}`; attr = `{${attr.value}}`;
} else if (attr && entry.type === 'mdxJsxExpressionAttribute') {
attr = `{${attr}}`
} else if (attr === null) { } else if (attr === null) {
attr = `{true}`; attr = "";
} else if (typeof attr === 'string') {
attr = `"${attr}"`;
} }
return Object.assign(acc, { [entry.name]: attr }); if (!entry.name) {
}, {}), return acc + ` ${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; return child;

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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
}
})
}
}

View file

@ -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)
}
}

View file

@ -11,7 +11,7 @@ import type { ShikiConfig } from './types.js';
const highlighterCacheAsync = new Map<string, Promise<shiki.Highlighter>>(); const highlighterCacheAsync = new Map<string, Promise<shiki.Highlighter>>();
const remarkShiki = async ( const remarkShiki = async (
{ langs, theme, wrap }: ShikiConfig, { langs = [], theme = 'github-dark', wrap = false }: ShikiConfig,
scopedClassName?: string | null scopedClassName?: string | null
) => { ) => {
const cacheID: string = typeof theme === 'string' ? theme : theme.name; const cacheID: string = typeof theme === 'string' ? theme : theme.name;

View file

@ -20,21 +20,23 @@ export type RehypePlugin<PluginParameters extends any[] = any[]> = unified.Plugi
export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlugin, any])[]; export type RehypePlugins = (string | [string, any] | RehypePlugin | [RehypePlugin, any])[];
export interface ShikiConfig { export interface ShikiConfig {
langs: ILanguageRegistration[]; langs?: ILanguageRegistration[];
theme: Theme | IThemeRegistration; theme?: Theme | IThemeRegistration;
wrap: boolean | null; wrap?: boolean | null;
} }
export interface AstroMarkdownOptions { export interface AstroMarkdownOptions {
mode: 'md' | 'mdx'; mode?: 'md' | 'mdx';
drafts: boolean; drafts?: boolean;
syntaxHighlight: 'shiki' | 'prism' | false; syntaxHighlight?: 'shiki' | 'prism' | false;
shikiConfig: ShikiConfig; shikiConfig?: ShikiConfig;
remarkPlugins: RemarkPlugins; remarkPlugins?: RemarkPlugins;
rehypePlugins: RehypePlugins; rehypePlugins?: RehypePlugins;
} }
export interface MarkdownRenderingOptions extends AstroMarkdownOptions { export interface MarkdownRenderingOptions extends AstroMarkdownOptions {
/** @internal */
fileURL?: URL;
/** @internal */ /** @internal */
$?: { $?: {
scopedClassName: string | null; scopedClassName: string | null;

View file

@ -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>`);
});
});

View file

@ -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>)}`);
})
});

View file

@ -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)));
});
})

View file

@ -1690,18 +1690,23 @@ importers:
packages/markdown/remark: packages/markdown/remark:
specifiers: specifiers:
'@astrojs/prism': ^0.4.1 '@astrojs/prism': ^0.4.1
'@types/chai': ^4.3.1
'@types/github-slugger': ^1.3.0 '@types/github-slugger': ^1.3.0
'@types/hast': ^2.3.4 '@types/hast': ^2.3.4
'@types/mdast': ^3.0.10 '@types/mdast': ^3.0.10
'@types/mocha': ^9.1.1
'@types/prismjs': ^1.26.0 '@types/prismjs': ^1.26.0
'@types/unist': ^2.0.6 '@types/unist': ^2.0.6
assert: ^2.0.0 assert: ^2.0.0
astro-scripts: workspace:* astro-scripts: workspace:*
chai: ^4.3.6
github-slugger: ^1.4.0 github-slugger: ^1.4.0
mdast-util-mdx-expression: ^1.2.0 mdast-util-mdx-expression: ^1.2.0
mdast-util-mdx-jsx: ^1.2.0 mdast-util-mdx-jsx: ^1.2.0
mdast-util-to-string: ^3.1.0 mdast-util-to-string: ^3.1.0
micromark-extension-mdx-jsx: ^1.0.3 micromark-extension-mdx-jsx: ^1.0.3
micromark-extension-mdxjs: ^1.0.0
mocha: ^9.2.2
prismjs: ^1.28.0 prismjs: ^1.28.0
rehype-raw: ^6.1.1 rehype-raw: ^6.1.1
rehype-stringify: ^9.0.3 rehype-stringify: ^9.0.3
@ -1713,6 +1718,7 @@ importers:
unified: ^10.1.2 unified: ^10.1.2
unist-util-map: ^3.1.1 unist-util-map: ^3.1.1
unist-util-visit: ^4.1.0 unist-util-visit: ^4.1.0
vfile: ^5.3.2
dependencies: dependencies:
'@astrojs/prism': link:../../astro-prism '@astrojs/prism': link:../../astro-prism
assert: 2.0.0 assert: 2.0.0
@ -1721,6 +1727,7 @@ importers:
mdast-util-mdx-jsx: 1.2.0 mdast-util-mdx-jsx: 1.2.0
mdast-util-to-string: 3.1.0 mdast-util-to-string: 3.1.0
micromark-extension-mdx-jsx: 1.0.3 micromark-extension-mdx-jsx: 1.0.3
micromark-extension-mdxjs: 1.0.0
prismjs: 1.28.0 prismjs: 1.28.0
rehype-raw: 6.1.1 rehype-raw: 6.1.1
rehype-stringify: 9.0.3 rehype-stringify: 9.0.3
@ -1732,13 +1739,18 @@ importers:
unified: 10.1.2 unified: 10.1.2
unist-util-map: 3.1.1 unist-util-map: 3.1.1
unist-util-visit: 4.1.0 unist-util-visit: 4.1.0
vfile: 5.3.2
devDependencies: devDependencies:
'@types/chai': 4.3.1
'@types/github-slugger': 1.3.0 '@types/github-slugger': 1.3.0
'@types/hast': 2.3.4 '@types/hast': 2.3.4
'@types/mdast': 3.0.10 '@types/mdast': 3.0.10
'@types/mocha': 9.1.1
'@types/prismjs': 1.26.0 '@types/prismjs': 1.26.0
'@types/unist': 2.0.6 '@types/unist': 2.0.6
astro-scripts: link:../../../scripts astro-scripts: link:../../../scripts
chai: 4.3.6
mocha: 9.2.2
packages/telemetry: packages/telemetry:
specifiers: specifiers:
@ -6976,7 +6988,6 @@ packages:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies: dependencies:
acorn: 8.7.1 acorn: 8.7.1
dev: true
/acorn-node/1.8.2: /acorn-node/1.8.2:
resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==} resolution: {integrity: sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==}
@ -10343,6 +10354,18 @@ packages:
micromark-util-types: 1.0.2 micromark-util-types: 1.0.2
dev: false 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: /micromark-extension-mdx-jsx/1.0.3:
resolution: {integrity: sha512-VfA369RdqUISF0qGgv2FfV7gGjHDfn9+Qfiv5hEwpyr1xscRj/CiVRkU7rywGFCO7JwJ5L0e7CJz60lY52+qOA==} resolution: {integrity: sha512-VfA369RdqUISF0qGgv2FfV7gGjHDfn9+Qfiv5hEwpyr1xscRj/CiVRkU7rywGFCO7JwJ5L0e7CJz60lY52+qOA==}
dependencies: dependencies:
@ -10357,6 +10380,38 @@ packages:
vfile-message: 3.1.2 vfile-message: 3.1.2
dev: false 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: /micromark-factory-destination/1.0.0:
resolution: {integrity: sha512-eUBA7Rs1/xtTVun9TmV3gjfPz2wEwgK5R5xcbIM5ZYAtvGF6JkyaDsj0agx8urXnO31tEO6Ug83iVH3tdedLnw==} resolution: {integrity: sha512-eUBA7Rs1/xtTVun9TmV3gjfPz2wEwgK5R5xcbIM5ZYAtvGF6JkyaDsj0agx8urXnO31tEO6Ug83iVH3tdedLnw==}
dependencies: dependencies: