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:
parent
78e962f744
commit
cfae9760b2
31 changed files with 542 additions and 108 deletions
6
.changeset/wicked-adults-pull.md
Normal file
6
.changeset/wicked-adults-pull.md
Normal 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.
|
|
@ -513,16 +513,6 @@ export interface AstroUserConfig {
|
|||
*/
|
||||
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
|
||||
* @name markdown.shikiConfig
|
||||
|
|
|
@ -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))
|
||||
)}`,
|
||||
|
|
|
@ -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);
|
||||
|
|
20
packages/astro/test/fixtures/astro-markdown/src/components/TextBlock.jsx
vendored
Normal file
20
packages/astro/test/fixtures/astro-markdown/src/components/TextBlock.jsx
vendored
Normal 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;
|
5
packages/astro/test/fixtures/astro-markdown/src/components/index.js
vendored
Normal file
5
packages/astro/test/fixtures/astro-markdown/src/components/index.js
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Counter from './Counter';
|
||||
|
||||
export default {
|
||||
Counter
|
||||
}
|
3
packages/astro/test/fixtures/astro-markdown/src/content/code-element.md
vendored
Normal file
3
packages/astro/test/fixtures/astro-markdown/src/content/code-element.md
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
This should have `nospace` around it.
|
||||
|
||||
This should have <code class="custom-class">nospace</code> around it.
|
12
packages/astro/test/fixtures/astro-markdown/src/pages/children.md
vendored
Normal file
12
packages/astro/test/fixtures/astro-markdown/src/pages/children.md
vendored
Normal 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>
|
7
packages/astro/test/fixtures/astro-markdown/src/pages/code-element.astro
vendored
Normal file
7
packages/astro/test/fixtures/astro-markdown/src/pages/code-element.astro
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
const content = await Astro.glob('../content/*.md');
|
||||
---
|
||||
|
||||
<div>
|
||||
{content.map(({ Content }) => <Content />)}
|
||||
</div>
|
2
packages/astro/test/fixtures/astro-markdown/src/pages/comment.md
vendored
Normal file
2
packages/astro/test/fixtures/astro-markdown/src/pages/comment.md
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
<!-- HTML comments! -->
|
||||
# It works!
|
|
@ -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>
|
||||
|
|
7
packages/astro/test/fixtures/astro-markdown/src/pages/namespace.md
vendored
Normal file
7
packages/astro/test/fixtures/astro-markdown/src/pages/namespace.md
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
setup: import ns from '../components/index.js';
|
||||
---
|
||||
|
||||
# Hello Namespace!
|
||||
|
||||
<ns.Counter>Click me!</ns.Counter>
|
7
packages/astro/test/fixtures/astro-markdown/src/pages/script.md
vendored
Normal file
7
packages/astro/test/fixtures/astro-markdown/src/pages/script.md
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Test
|
||||
|
||||
## Let's try a script...
|
||||
|
||||
This should work!
|
||||
|
||||
<script src="/src/scripts/test.js" />
|
7
packages/astro/test/fixtures/astro-markdown/src/pages/slug.md
vendored
Normal file
7
packages/astro/test/fixtures/astro-markdown/src/pages/slug.md
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title: My Blog Post
|
||||
---
|
||||
|
||||
# {frontmatter.title}
|
||||
|
||||
Hello world
|
1
packages/astro/test/fixtures/astro-markdown/src/scripts/test.js
vendored
Normal file
1
packages/astro/test/fixtures/astro-markdown/src/scripts/test.js
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
console.log("Hello world");
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
18
packages/markdown/remark/src/mdast-util-mdxish.ts
Normal file
18
packages/markdown/remark/src/mdast-util-mdxish.ts
Normal 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,
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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}"`;
|
||||
}
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
81
packages/markdown/remark/src/remark-mark-and-unravel.ts
Normal file
81
packages/markdown/remark/src/remark-mark-and-unravel.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
15
packages/markdown/remark/src/remark-mdxish.ts
Normal file
15
packages/markdown/remark/src/remark-mdxish.ts
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -20,21 +20,23 @@ 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;
|
||||
|
|
62
packages/markdown/remark/test/components.test.js
Normal file
62
packages/markdown/remark/test/components.test.js
Normal 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>`);
|
||||
});
|
||||
});
|
40
packages/markdown/remark/test/expressions.test.js
Normal file
40
packages/markdown/remark/test/expressions.test.js
Normal 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>)}`);
|
||||
})
|
||||
});
|
26
packages/markdown/remark/test/plugins.test.js
Normal file
26
packages/markdown/remark/test/plugins.test.js
Normal 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)));
|
||||
});
|
||||
})
|
|
@ -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:
|
||||
|
|
Loading…
Reference in a new issue