Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion e2e/fixtures/plugin-playground/doc/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ export default function HelloWorld() {
}
```

<code src="./component.jsx" />
```jsx file="./component.jsx"

```

```jsx direction=vertical
export default function HelloWorld() {
Expand Down
4 changes: 3 additions & 1 deletion e2e/fixtures/plugin-preview-custom-entry/doc/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export default function HelloWorld() {
}
```

{/* <code src="./Demo.tsx" previewMode="iframe"/> */}
```tsx file="./component.jsx" iframe

```

```vue iframe
<template>
Expand Down
10 changes: 8 additions & 2 deletions e2e/fixtures/plugin-preview-custom-entry/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,21 @@ test.describe('plugin test', async () => {
.nth(1)
.getByText('TSX')
.innerText();
const transformedCodePreview = await page
const externalIframeJsxDemoCodePreview = await page
.frameLocator('iframe')
.nth(2)
.getByText('EXTERNAL')
.innerText();
const transformedCodePreview = await page
.frameLocator('iframe')
.nth(3)
.getByText('VUE')
.innerText();

expect(codeBlockElements.length).toBe(3);
expect(codeBlockElements.length).toBe(4);
expect(internalIframeJsxDemoCodePreview).toBe('Hello World JSX');
expect(internalIframeTsxDemoCodePreview).toBe('Hello World TSX');
expect(externalIframeJsxDemoCodePreview).toBe('Hello World External');
expect(transformedCodePreview).toBe('Hello World VUE');
});
});
4 changes: 3 additions & 1 deletion e2e/fixtures/plugin-preview/doc/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ export default function HelloWorld() {
}
```

{/* <code src="./component.jsx" /> */}
```jsx file="./component.jsx"

```

```json
{
Expand Down
14 changes: 6 additions & 8 deletions e2e/fixtures/plugin-preview/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,18 @@ test.describe('plugin test', async () => {
.frameLocator('iframe')
.getByText('Internal')
.innerText();
// FIXME: support this usage of plugin-preview
// const externalDemoCodePreview = await page
// .frameLocator('iframe')
// .getByText('External')
// .innerText();
const externalDemoCodePreview = await page
.frameLocator('iframe')
.getByText('External')
.innerText();
const transformedCodePreview = await page
.frameLocator('iframe')
.getByText('JSON')
.innerText();

// expect(codeBlockElements.length).toBe(3);
expect(codeBlockElements.length).toBe(2);
expect(codeBlockElements.length).toBe(3);
expect(internalDemoCodePreview).toBe('Hello World Internal');
// expect(externalDemoCodePreview).toBe('Hello World External');
expect(externalDemoCodePreview).toBe('Hello World External');
expect(transformedCodePreview).toBe('Render from JSON');
});
});
8 changes: 7 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// TODO: do not expose remarkPluginNormalizeLink as publicAPI
export { dev, build, serve, remarkPluginNormalizeLink } from './node';
export {
dev,
build,
serve,
remarkPluginNormalizeLink,
remarkFileCodeBlock,
} from './node';
export * from '@rspress/shared';
export { mergeDocConfig } from '@rspress/shared/node-utils';
export type { RouteService } from './node/route/RouteService';
1 change: 1 addition & 0 deletions packages/core/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { build } from './build';
export { serve } from './serve';

export { remarkPluginNormalizeLink } from './mdx/remarkPlugins/normalizeLink';
export { remarkFileCodeBlock } from './mdx/remarkPlugins/fileCodeBlock';
2 changes: 2 additions & 0 deletions packages/core/src/node/mdx/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { RouteService } from '../route/RouteService';
import { rehypeCodeMeta } from './rehypePlugins/codeMeta';
import { createRehypeShikiOptions } from './rehypePlugins/shiki';
import { remarkContainerSyntax } from './remarkPlugins/containerSyntax';
import { remarkFileCodeBlock } from './remarkPlugins/fileCodeBlock';

export async function createMDXOptions(options: {
docDirectory: string;
Expand Down Expand Up @@ -64,6 +65,7 @@ export async function createMDXOptions(options: {
remarkGFM,
remarkPluginToc,
remarkContainerSyntax,
[remarkFileCodeBlock, { filepath }],
[
remarkPluginNormalizeLink,
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ function parseTitleFromMeta(meta: string | undefined): string {
if (!meta) {
return '';
}
let result = meta;
const highlightReg = /{[\d,-]*}/i;
const highlightMeta = highlightReg.exec(meta)?.[0];
if (highlightMeta) {
result = meta.replace(highlightReg, '').trim();
const kvList = meta.split(' ').filter(Boolean) as string[];
for (const item of kvList) {
const [k, v] = item.split('=');
if (k === 'title' && v.length > 0) {
return v.replace(/["'`]/g, '');
}
}
result = result.split('=')[1] ?? '';
return result?.replace(/["'`]/g, '');
return '';
}

export function transformerAddTitle(): ShikiTransformer {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/
import type { ShikiTransformer } from 'shiki';

export function parseMetaHighlightString(meta: string): number[] | null {
function parseMetaHighlightString(meta: string): number[] | null {
if (!meta) return null;
const match = meta.match(/\{([\d,-]+)\}/);
if (!match) return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`remarkFileCodeBlock > basic 1`] = `
"const frontmatter = {};
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import {useMDXComponents as _provideComponents} from "@mdx-js/react";
function _createMdxContent(props) {
const _components = {
code: "code",
pre: "pre",
span: "span",
..._provideComponents(),
...props.components
};
return _jsx(_Fragment, {
children: _jsx(_Fragment, {
children: _jsx(_components.pre, {
className: "shiki css-variables",
style: {
backgroundColor: "var(--shiki-background)",
color: "var(--shiki-foreground)"
},
tabIndex: "0",
children: _jsxs(_components.code, {
className: "language-jsx",
children: [_jsxs(_components.span, {
className: "line",
children: [_jsx(_components.span, {
style: {
color: "var(--shiki-token-keyword)"
},
children: "export"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-token-keyword)"
},
children: " default"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-foreground)"
},
children: " () "
}), _jsx(_components.span, {
style: {
color: "var(--shiki-token-keyword)"
},
children: "=>"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-foreground)"
},
children: " {"
})]
}), "\\n", _jsxs(_components.span, {
className: "line",
children: [_jsx(_components.span, {
style: {
color: "var(--shiki-token-keyword)"
},
children: " return"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-foreground)"
},
children: " <"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-token-string-expression)"
},
children: "div"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-foreground)"
},
children: ">hello world</"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-token-string-expression)"
},
children: "div"
}), _jsx(_components.span, {
style: {
color: "var(--shiki-foreground)"
},
children: ">;"
})]
}), "\\n", _jsx(_components.span, {
className: "line",
children: _jsx(_components.span, {
style: {
color: "var(--shiki-foreground)"
},
children: "}"
})
}), "\\n", _jsx(_components.span, {
className: "line"
})]
})
})
})
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = {
..._provideComponents(),
...props.components
};
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}

MDXContent.__RSPRESS_PAGE_META = {};

MDXContent.__RSPRESS_PAGE_META["index.mdx"] = {"toc":[],"title":"","headingTitle":"","frontmatter":{}};
"
`;
32 changes: 32 additions & 0 deletions packages/core/src/node/mdx/remarkPlugins/fileCodeBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { fs, vol } from 'memfs';

import { describe, expect, it, vi } from 'vitest';
import { compile } from '../processor';

vi.mock('node:fs/promises', () => {
return { readFile: fs.promises.readFile };
});

describe('remarkFileCodeBlock', () => {
it('basic', async () => {
vol.fromJSON({
'/usr/rspress-project/docs/_Demo.jsx': `export default () => {
return <div>hello world</div>;
}
`,
});
const result = await compile({
source: `
\`\`\`jsx file="./_Demo.jsx"
\`\`\`
`,
checkDeadLinks: false,
docDirectory: '/usr/rspress-project/docs',
filepath: '/usr/rspress-project/docs/index.mdx',
config: null,
pluginDriver: null,
routeService: null,
});
expect(result).toMatchSnapshot();
});
});
79 changes: 79 additions & 0 deletions packages/core/src/node/mdx/remarkPlugins/fileCodeBlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { logger } from '@rspress/shared/logger';
import type { Root } from 'mdast';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';

const ERROR_PREFIX = '[remarkFileCodeBlock]';

function parseFileFromMeta(meta: string | undefined): string {
if (!meta) {
return '';
}
const kvList = meta.split(' ').filter(Boolean) as string[];
for (const item of kvList) {
const [k, v] = item.split('=');
if (k === 'file' && v.length > 0) {
return v.replace(/["'`]/g, '');
}
}
return '';
}

export const remarkFileCodeBlock: Plugin<[{ filepath: string }], Root> = ({
filepath,
}) => {
return async tree => {
const promiseList: Promise<void>[] = [];
visit(tree, 'code', node => {
const { meta, value } = node;
const file = parseFileFromMeta(meta ?? '');

if (!file) {
return;
}

if (file.startsWith('./') || file.startsWith('../')) {
const resolvedFilePath = path.join(path.dirname(filepath), file);
// we allow blank lines or spaces, which may be necessary due to formatting tools and other reasons.
if (value.trim() !== '') {
logger.error(`${ERROR_PREFIX} The content of file code block should be empty.

\`\`\`tsx file="./filename"
content
\`\`\`

this usage is not allowed, please use below:

\`\`\`tsx file="./filename"
\`\`\`
`);
throw new Error(
`${ERROR_PREFIX} The content of file code block should be empty.`,
);
}

const promise = readFile(resolvedFilePath, 'utf-8')
.then(fileContent => {
node.value = fileContent;
})
.catch(e => {
logger.error(`${ERROR_PREFIX} The file does not exist.
\`file="${file}"\` is resolved to ${resolvedFilePath}"`);
throw e;
});

promiseList.push(promise);
return;
}

// TODO: support resolve.alias with rspack-resolver
throw new Error(
`${ERROR_PREFIX} The file path should use relative path "./" or "../"`,
);
});

await Promise.all(promiseList);
};
};
Loading