Skip to content
Open
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
173 changes: 173 additions & 0 deletions mdx/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# @effectionx/mdx

MDX and Markdown processing utilities for [Effection](https://frontside.com/effection) - evaluate MDX content with structured concurrency.

## Installation

```bash
npm install @effectionx/mdx
```

## Features

- **useMDX** - Low-level MDX evaluation with Effection
- **useMarkdown** - Convenience wrapper with common plugins pre-configured
- **replaceAll** - Async regex replacement helper
- **createJsDocSanitizer** - Convert JSDoc `@link` syntax to markdown links

## Usage

### Basic MDX Evaluation

The `useMDX` function evaluates MDX content. You must provide your own JSX runtime:

```typescript
import { main } from "effection";
import { useMDX } from "@effectionx/mdx";
import { jsx, jsxs, Fragment } from "react/jsx-runtime";

await main(function* () {
const mdxModule = yield* useMDX("# Hello **World**", {
jsx,
jsxs,
Fragment,
});

// Render the content
const Content = mdxModule.default;
return <Content />;
});
```

### Markdown with Common Plugins

The `useMarkdown` function includes common plugins out of the box:

- GitHub Flavored Markdown (tables, strikethrough, etc.)
- Syntax highlighting with Prism
- Heading slugs for anchor links
- Autolink headings
- JSDoc `@link` sanitization

```typescript
import { main } from "effection";
import { useMarkdown } from "@effectionx/mdx";
import { jsx, jsxs, Fragment } from "react/jsx-runtime";

await main(function* () {
const element = yield* useMarkdown(markdownContent, {
jsx,
jsxs,
Fragment,
});

// element is ready to render
return element;
});
```

### Custom Link Resolution

When processing JSDoc-style documentation, you can customize how `@link` references are resolved:

```typescript
import { useMarkdown } from "@effectionx/mdx";

const element = yield* useMarkdown(docString, {
jsx,
jsxs,
Fragment,
linkResolver: function* (symbol, connector, method) {
const name = [symbol, connector, method].filter(Boolean).join("");
return `[${name}](/api/${symbol}${method ? `#${method}` : ""})`;
},
});
```

### Async Regex Replacement

The `replaceAll` function allows async replacements using Effection operations:

```typescript
import { replaceAll } from "@effectionx/mdx";

const result = yield* replaceAll(
"Hello {{name}}, welcome to {{place}}!",
/\{\{(\w+)\}\}/g,
function* (match) {
const [, key] = match;
// Could fetch from database, API, etc.
return yield* fetchValue(key);
},
);
```

### JSDoc Sanitization

Convert JSDoc `@link` syntax to markdown before MDX processing:

```typescript
import { createJsDocSanitizer } from "@effectionx/mdx";

const sanitize = createJsDocSanitizer();

// "{@link Context}" -> "[Context](Context)"
// "{@link Scope.run}" -> "[Scope.run](Scope.run)"
const cleaned = yield* sanitize(jsDocString);
```

## API

### useMDX(markdown, options)

Evaluate MDX content and return the resulting module.

**Options:**
- `jsx` - JSX factory function (required)
- `jsxs` - JSX factory for multiple children (required)
- `Fragment` - Fragment component (required)
- `remarkPlugins` - Additional remark plugins
- `rehypePlugins` - Additional rehype plugins
- `remarkRehypeOptions` - Options for remark-rehype

### useMarkdown(markdown, options)

Parse and evaluate markdown with common plugins pre-configured.

**Options:**
- All `useMDX` options, plus:
- `linkResolver` - Custom JSDoc link resolver
- `slugPrefix` - Prefix for heading slugs
- `showLineNumbers` - Show line numbers in code blocks (default: true)

### replaceAll(input, regex, replacement)

Asynchronously replace all regex matches in a string.

- `input` - The input string
- `regex` - The pattern to match
- `replacement` - Generator function that returns the replacement string

### createJsDocSanitizer(resolver?)

Create a function that sanitizes JSDoc `@link` syntax.

- `resolver` - Optional custom link resolver

## Included Plugins

When using `useMarkdown`, these plugins are included by default:

- [remark-gfm](https://github.com/remarkjs/remark-gfm) - GitHub Flavored Markdown
- [rehype-prism-plus](https://github.com/timlrx/rehype-prism-plus) - Syntax highlighting
- [rehype-slug](https://github.com/rehypejs/rehype-slug) - Add IDs to headings
- [rehype-autolink-headings](https://github.com/rehypejs/rehype-autolink-headings) - Add links to headings

## Requirements

- Node.js >= 22
- Effection ^3 || ^4

## License

MIT
101 changes: 101 additions & 0 deletions mdx/jsdoc-sanitizer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, it } from "@effectionx/bdd";
import { expect } from "expect";

import {
createJsDocSanitizer,
defaultLinkResolver,
} from "./jsdoc-sanitizer.ts";

describe("jsdoc-sanitizer", () => {
describe("defaultLinkResolver", () => {
it("resolves simple symbol", function* () {
const result = yield* defaultLinkResolver("Context");
expect(result).toBe("[Context](Context)");
});

it("resolves symbol with dot method", function* () {
const result = yield* defaultLinkResolver("Scope", ".", "run");
expect(result).toBe("[Scope.run](Scope.run)");
});

it("resolves symbol with hash method", function* () {
const result = yield* defaultLinkResolver("Scope", "#", "run");
expect(result).toBe("[Scope#run](Scope#run)");
});

it("returns empty string for empty symbol", function* () {
const result = yield* defaultLinkResolver("");
expect(result).toBe("");
});
});

describe("createJsDocSanitizer", () => {
const sanitize = createJsDocSanitizer();

it("converts {@link Symbol} to markdown link", function* () {
const result = yield* sanitize("{@link Context}");
expect(result).toBe("[Context](Context)");
});

it("converts @{link Symbol} to markdown link", function* () {
const result = yield* sanitize("@{link Scope}");
expect(result).toBe("[Scope](Scope)");
});

it("handles function syntax {@link fn()}", function* () {
const result = yield* sanitize("{@link spawn()}");
expect(result).toBe("[spawn](spawn)");
});

it("handles dot method reference", function* () {
const result = yield* sanitize("{@link Scope.run}");
expect(result).toBe("[Scope.run](Scope.run)");
});

it("handles hash method reference", function* () {
const result = yield* sanitize("{@link Scope#run}");
expect(result).toBe("[Scope#run](Scope#run)");
});

it("handles complex invalid link syntax", function* () {
// This pattern from the original tests - complex links with extra content
const result = yield* sanitize(
"{@link * establish error boundaries https://frontside.com/effection/docs/errors | error boundaries}",
);
expect(result).toBe("");
});

it("handles multiple links in one string", function* () {
const result = yield* sanitize("{@link Operation}&lt;{@link T}&gt;");
expect(result).toBe("[Operation](Operation)&lt;[T](T)&gt;");
});

it("preserves text without links", function* () {
const result = yield* sanitize("This is regular text without links.");
expect(result).toBe("This is regular text without links.");
});

it("handles mixed content", function* () {
const result = yield* sanitize(
"Returns a {@link Context} that can be used with {@link Scope.run}.",
);
expect(result).toBe(
"Returns a [Context](Context) that can be used with [Scope.run](Scope.run).",
);
});
});

describe("custom link resolver", () => {
it("uses custom resolver for link generation", function* () {
const sanitize = createJsDocSanitizer(
function* (symbol, connector, method) {
const name = [symbol, connector, method].filter(Boolean).join("");
return `[${name}](/api/${symbol}${method ? `#${method}` : ""})`;
},
);

const result = yield* sanitize("{@link Scope.run}");
expect(result).toBe("[Scope.run](/api/Scope#run)");
});
});
});
92 changes: 92 additions & 0 deletions mdx/jsdoc-sanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Operation } from "effection";
import { replaceAll } from "./replace-all.ts";

/**
* Function type for resolving JSDoc `@link` references to markdown links.
*
* @param symbol - The main symbol being linked (e.g., "Context", "Scope")
* @param connector - Optional connector between symbol and method (e.g., ".", "#")
* @param method - Optional method name (e.g., "run")
* @returns The markdown link string
*/
export type LinkResolver = (
symbol: string,
connector?: string,
method?: string,
) => Operation<string>;

/**
* Default link resolver that creates simple markdown links.
*
* @example
* ```ts
* // "Context" -> "[Context](Context)"
* // "Scope.run" -> "[Scope.run](Scope.run)"
* ```
*/
export function* defaultLinkResolver(
symbol: string,
connector?: string,
method?: string,
): Operation<string> {
const parts = [symbol];
if (symbol && connector && method) {
parts.push(connector, method);
}
const name = parts.filter(Boolean).join("");
if (name) {
return `[${name}](${name})`;
}
return "";
}

/**
* Create a sanitizer function that converts JSDoc `@link` syntax to markdown links.
*
* MDX throws parse errors when encountering JSDoc `{@link }` syntax, so this
* sanitizer converts them to standard markdown links before MDX processing.
*
* @param resolver - Optional custom link resolver function
* @returns A function that sanitizes JSDoc links in a string
*
* @example
* ```ts
* import { createJsDocSanitizer } from "@effectionx/mdx";
*
* const sanitize = createJsDocSanitizer();
*
* // Basic usage
* const result = yield* sanitize("{@link Context}");
* // result: "[Context](Context)"
*
* // With method reference
* const result2 = yield* sanitize("{@link Scope.run}");
* // result2: "[Scope.run](Scope.run)"
* ```
*
* @example
* ```ts
* // Custom resolver for API documentation links
* const sanitize = createJsDocSanitizer(function* (symbol, connector, method) {
* const name = [symbol, connector, method].filter(Boolean).join("");
* return `[${name}](/api/${symbol}${method ? `#${method}` : ""})`;
* });
*
* const result = yield* sanitize("{@link Scope.run}");
* // result: "[Scope.run](/api/Scope#run)"
* ```
*/
export function createJsDocSanitizer(
resolver: LinkResolver = defaultLinkResolver,
): (doc: string) => Operation<string> {
return function* sanitizeJsDoc(doc: string): Operation<string> {
return yield* replaceAll(
doc,
/@?{@?link\s*(\w*)([^\w}])?(\w*)?([^}]*)?}/gm,
function* (match) {
const [, symbol, connector, method] = match;
return yield* resolver(symbol, connector, method);
},
);
};
}
15 changes: 15 additions & 0 deletions mdx/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export { replaceAll } from "./replace-all.ts";
export {
createJsDocSanitizer,
defaultLinkResolver,
type LinkResolver,
} from "./jsdoc-sanitizer.ts";
export {
useMDX,
type UseMDXOptions,
type JSXRuntime,
} from "./use-mdx.ts";
export {
useMarkdown,
type UseMarkdownOptions,
} from "./use-markdown.ts";
Loading
Loading