Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/transform-rspress-code-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@module-federation/rspress-plugin": patch
---

Allow consumers to replace fenced code block content in exposed Rspress documents.
55 changes: 36 additions & 19 deletions packages/rspress-plugin/README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,50 @@
# @examples/mf-react-component
# @module-federation/rspress-plugin

This example demonstrates how to use Rslib to build a simple Module Federation React component.
Module Federation integration for Rspress.

### Command
## Transform remote code blocks

Build package
Enable the capability on the Rspress producer:

```
nx build rslib-module
```ts
pluginModuleFederation(mfConfig, {
transformCodeBlocks: true,
});
```

Serve package
The producer keeps authoring regular Markdown. It does not need to add an id or
create a code-block component:

```
nx serve rslib-module
```
````md
## View all commands

Dev package
```bash
npx mf -h
```
````

1.
The consumer creates a transformer with the browser-safe runtime entry and
passes it to the remote MDX document:

```
nx dev rslib-module
```
```tsx
import Cli from 'mf-doc/cli-en';
import { transformCodeBlock } from '@module-federation/rspress-plugin/runtime';

2.
export const replaceCliName = transformCodeBlock({
replace: [[/\bmf\b/g, 'vmok']],
filter: ({ lang }) => lang === 'bash' || lang === 'text',
});

export default function Page() {
return <Cli name="Vmok" cmd="vmok" transformCodeBlock={replaceCliName} />;
}
```
nx storybook rslib-module
```

visit http://localhost:6006
Replacement rules run in order. Without `filter`, they are considered for every
fenced code block in the remote document; blocks with no matching content keep
their original highlighting. A transformer can also return a different
language when the replacement changes the code type.

The transformation runs while rendering the remote MDX document, so the same
result is used by browser rendering and SSG. Existing HTML-based llms/Markdown
rebuilds also read the transformed output.
14 changes: 13 additions & 1 deletion packages/rspress-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
".": {
"types": "./dist/plugin.d.ts",
"import": "./dist/index.js"
},
"./runtime": {
"types": "./dist/runtime/index.d.ts",
"import": "./dist/runtime.js"
}
},
"module": "./dist/index.js",
Expand All @@ -33,17 +37,24 @@
"build": "rslib build",
"build:watch": "rslib build --watch",
"dev": "pnpm run build:watch",
"test": "rstest",
"pre-release": "pnpm exec turbo run build --filter=@module-federation/webpack-bundler-runtime"
},
"devDependencies": {
"@mdx-js/mdx": "^3.1.1",
"@rslib/core": "^0.23.2",
"@rspress/core": "2.0.14",
"@rstest/core": "^0.10.6",
"@types/html-to-text": "^9.0.4",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"typescript": "7.0.2"
},
"dependencies": {
"@mdx-js/react": "^3.1.1",
"cheerio": "1.0.0-rc.12",
"@module-federation/sdk": "workspace:*",
"html-to-text": "9.0.5",
Expand All @@ -54,6 +65,7 @@
"@rspress/shared": "2.0.14"
},
"peerDependencies": {
"@rspress/core": "2.0.14"
"@rspress/core": "2.0.14",
"react": ">=18.0.0"
}
}
2 changes: 2 additions & 0 deletions packages/rspress-plugin/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export default defineConfig({
source: {
entry: {
index: 'src/plugin.ts',
runtime: 'src/runtime/index.tsx',
},
tsconfigPath: './tsconfig.lib.json',
},
lib: [
{
Expand Down
5 changes: 5 additions & 0 deletions packages/rspress-plugin/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
testEnvironment: 'node',
});
16 changes: 15 additions & 1 deletion packages/rspress-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,19 @@ import type { moduleFederationPlugin } from '@module-federation/sdk';
import type { RspressPlugin, RouteMeta } from '@rspress/core';
import { rebuildLlmsByHtml } from './rebuildLlmsByHtml';
import { rebuildSearchIndexByHtml } from './rebuildSearchIndexByHtml';
import { remarkCodeBlockTransform } from './remarkCodeBlockTransform';

type RspressPluginOptions = {
export type RspressPluginOptions = {
autoShared?: boolean;
rebuildSearchIndex?: boolean;
rebuildLlms?: boolean;
/**
* Allow exposed MDX documents to transform fenced code blocks through a
* `transformCodeBlock` prop.
*
* @default false
*/
transformCodeBlocks?: boolean;
};

const isDev = () => process.env.NODE_ENV === 'development';
Expand All @@ -25,6 +33,7 @@ export function pluginModuleFederation(
autoShared = true,
rebuildSearchIndex = true,
rebuildLlms = true,
transformCodeBlocks = false,
} = rspressOptions || {};

if (autoShared) {
Expand Down Expand Up @@ -66,6 +75,11 @@ export function pluginModuleFederation(

return {
name: 'plugin-module-federation',
markdown: transformCodeBlocks
? {
remarkPlugins: [remarkCodeBlockTransform],
}
: undefined,
async config(config) {
if (!isDev() && config.ssg !== false) {
enableSSG = true;
Expand Down
85 changes: 85 additions & 0 deletions packages/rspress-plugin/src/remarkCodeBlockTransform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { compile } from '@mdx-js/mdx';
import { compile as compileRspressMdx } from '@rspress/core/dist/node/mdx/processor.js';
import type { PluginDriver } from '@rspress/core/dist/node/PluginDriver.js';
import { describe, expect, it } from '@rstest/core';
import path from 'node:path';
import { pluginModuleFederation } from './plugin';
import {
codeBlockTransformRuntimeModule,
remarkCodeBlockTransform,
} from './remarkCodeBlockTransform';

describe('remarkCodeBlockTransform', () => {
it('injects the runtime as the implicit MDX layout', async () => {
const output = String(
await compile('# Title\n\n```bash\nnpx mf -h\n```', {
providerImportSource: '@mdx-js/react',
remarkPlugins: [remarkCodeBlockTransform],
}),
);

expect(output).toContain(
`import MDXLayout from "${codeBlockTransformRuntimeModule}";`,
);
expect(output).toContain('_jsx(MDXLayout');
expect(output).toContain('...props');
});

it('preserves an explicit user layout', async () => {
const source = [
'export default function CustomLayout({ children }) {',
' return <section>{children}</section>;',
'}',
'',
'# Title',
].join('\n');
const output = String(
await compile(source, {
providerImportSource: '@mdx-js/react',
remarkPlugins: [remarkCodeBlockTransform],
}),
);

expect(output).toContain('function CustomLayout');
expect(output).not.toContain(codeBlockTransformRuntimeModule);
});

it('works in the real Rspress Markdown pipeline', async () => {
const plugin = pluginModuleFederation(
{ name: 'rspress-code-block-test' },
{ transformCodeBlocks: true },
);
const docDirectory = process.cwd();
const filepath = path.join(docDirectory, 'code-block-test.mdx');
const pluginDriver = {
getPlugins: () => [plugin],
} as PluginDriver;

const output = await compileRspressMdx({
source: [
'# Commands',
'',
'Other content.',
'',
'```bash title=cli wrapCode lineNumbers',
'npx mf -h',
'```',
].join('\n'),
filepath,
docDirectory,
config: { markdown: {} },
routeService: null,
pluginDriver,
});

expect(output).toContain(
`import MDXLayout from "${codeBlockTransformRuntimeModule}";`,
);
expect(output).toContain('className: "shiki css-variables"');
expect(output).toContain('lang: "bash"');
expect(output).toContain('title: "cli"');
expect(output).toContain('lineNumbers: true');
expect(output).toContain('wrapCode: true');
expect(output).toContain('"Other content."');
});
});
111 changes: 111 additions & 0 deletions packages/rspress-plugin/src/remarkCodeBlockTransform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
const RUNTIME_MODULE = '@module-federation/rspress-plugin/runtime';

type EstreeNode = {
type: string;
[key: string]: unknown;
};

type EstreeProgram = {
type: 'Program';
sourceType: 'module';
body: EstreeNode[];
};

type MdxNode = {
type: string;
data?: {
estree?: EstreeProgram;
};
};

type MdxRoot = {
children: MdxNode[];
};

function hasDefaultExport(tree: MdxRoot): boolean {
return tree.children.some((node) =>
node.data?.estree?.body?.some((statement) => {
if (statement.type === 'ExportDefaultDeclaration') {
return true;
}
if (statement.type !== 'ExportNamedDeclaration') {
return false;
}
const specifiers = statement.specifiers;
return (
Array.isArray(specifiers) &&
specifiers.some((specifier) => {
if (
typeof specifier !== 'object' ||
specifier === null ||
!('exported' in specifier)
) {
return false;
}
const exported = specifier.exported;
return (
typeof exported === 'object' &&
exported !== null &&
'name' in exported &&
exported.name === 'default'
);
})
);
}),
);
}

function createLayoutExportNode(): MdxNode {
const rawSource = JSON.stringify(RUNTIME_MODULE);
return {
type: 'mdxjsEsm',
data: {
estree: {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ExportNamedDeclaration',
declaration: null,
specifiers: [
{
type: 'ExportSpecifier',
local: {
type: 'Identifier',
name: 'default',
},
exported: {
type: 'Identifier',
name: 'default',
},
},
],
source: {
type: 'Literal',
value: RUNTIME_MODULE,
raw: rawSource,
},
},
],
},
},
};
}

/**
* Install the plugin runtime as the implicit MDX layout. MDX renders the
* document body as the layout's child, so the layout can provide code-block
* components before the body is evaluated.
*/
export function remarkCodeBlockTransform() {
return (tree: MdxRoot) => {
// Preserve an explicit user-authored MDX layout. Consumers can still use
// the runtime API directly around such documents.
if (hasDefaultExport(tree)) {
return;
}
tree.children.unshift(createLayoutExportNode());
};
}

export const codeBlockTransformRuntimeModule = RUNTIME_MODULE;
Loading
Loading