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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun test
- run: bun run test
- run: bun run build
37 changes: 37 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Publish

permissions:
contents: read

on:
push:
tags:
- "v*"
workflow_dispatch:

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
# renovate: datasource=github-releases depName=oven-sh/bun
bun-version: "1.3.14"
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun run test
- run: bun run build
- name: Resolve npm dist-tag from version
id: disttag
run: |
version=$(node -p "require('./package.json').version")
if [[ "$version" == *-* ]]; then
echo "tag=next" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- run: bun publish --access public --tag ${{ steps.disttag.outputs.tag }}
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
220 changes: 198 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,25 @@

# `solid-markdown`

Render markdown as solid components.
Render markdown to Solid components.

The implementation is 90% shamelessly copied from https://github.com/remarkjs/react-markdown.
`solid-markdown` now tracks the `react-markdown` 10.x API closely and keeps the rendering pipeline upstream-aligned while adapting the JSX output to Solid.

Changes include:
## Why this update matters

- Replacing React specific component creation with SolidJS components
- Porting the implementation from javascript with JSDoc types to typescript

Please check the original repo for in-depth details on how to use.
This package is now a real Solid port of modern `react-markdown`, not a compatibility wrapper around older behavior. The public API matches upstream concepts such as `components`, `remarkRehypeOptions`, `urlTransform`, and async plugin support, while Solid gets one extra client-side helper: `MarkdownResource`.

## Installation

```bash
npm install solid-markdown
bun add @rigelbuild/solid-markdown
```


## Usage

```jsx
import { SolidMarkdown } from "solid-markdown";
```tsx
import Markdown from "@rigelbuild/solid-markdown";
import remarkGfm from "remark-gfm";

const markdown = `
# This is a title
Expand All @@ -33,25 +31,203 @@ const markdown = `
- a
- list
`;
const App = () => {
return <SolidMarkdown children={markdown} />;

export default function App() {
return <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>;
}
```

## API reference

| Export | Type | Purpose |
| --- | --- | --- |
| `Markdown` | component | Synchronous markdown renderer. |
| `MarkdownAsync` | function | Async/server renderer for async unified plugins. |
| `MarkdownResource` | component | Solid client wrapper for async plugin pipelines. |
| `defaultUrlTransform` | function | Default URL sanitizer used for links and images. |
| `AllowElement` | type | Per-element allow/deny callback. |
| `Components` | type | Custom tag-to-component overrides. |
| `ExtraProps` | type | Extra props passed to custom components (`node`). |
| `Options` | type | Shared renderer options. |
| `MarkdownResourceOptions` | type | `Options` plus `fallback`. |
| `UrlTransform` | type | URL rewrite/sanitization hook. |

### `Markdown`

Synchronous markdown renderer.

```tsx
import Markdown from "@rigelbuild/solid-markdown";
import remarkGfm from "remark-gfm";

<Markdown remarkPlugins={[remarkGfm]}>{value()}</Markdown>;
```

### `MarkdownAsync`

Async/server helper for async unified plugins.

```tsx
import { MarkdownAsync } from "@rigelbuild/solid-markdown";
import rehypeStarryNight from "rehype-starry-night";

const content = await MarkdownAsync({
children: "```js\nconsole.log(3.14)\n```",
rehypePlugins: [rehypeStarryNight],
});

return <div class="preview">{content}</div>;
```

### `MarkdownResource`

Solid-native client wrapper for async plugins.

```tsx
import { MarkdownResource } from "@rigelbuild/solid-markdown";
import rehypeStarryNight from "rehype-starry-night";

<MarkdownResource
children={value()}
fallback={<p>Rendering…</p>}
rehypePlugins={[rehypeStarryNight]}
/>;
```

### `defaultUrlTransform`

By default, unsafe protocols such as `javascript:` are removed while standard URLs, fragments, and paths are preserved.

```tsx
import Markdown, { defaultUrlTransform } from "@rigelbuild/solid-markdown";

<Markdown
urlTransform={(url, key, node) => {
const safe = defaultUrlTransform(url);
if (!safe) return safe;
return key === "href" && node.tagName === "a" ? `/out?url=${encodeURIComponent(safe)}` : safe;
}}
>
{"[OpenAI](https://openai.com)"}
</Markdown>;
```

## Options

Supported options match upstream `react-markdown` 10.x semantics:

| Option | Purpose |
| --- | --- |
| `allowElement` | Decide per HAST element whether it should render. |
| `allowedElements` | Allowlist tag names. |
| `children` | Markdown source string. `null` and `undefined` render nothing. |
| `components` | Override specific HTML tags with Solid components or tag names. |
| `disallowedElements` | Blocklist tag names. |
| `rehypePlugins` | Rehype plugins applied after markdown is converted to HAST. |
| `remarkPlugins` | Remark plugins applied while parsing markdown. |
| `remarkRehypeOptions` | Extra `remark-rehype` options merged with the safe defaults used by upstream. |
| `skipHtml` | Ignore raw HTML in the markdown source. |
| `unwrapDisallowed` | Keep children of removed nodes instead of dropping the whole subtree. |
| `urlTransform` | Rewrite or sanitize link and image URLs. |

## Components

Custom components receive normal Solid intrinsic props plus `node`.

```tsx
import Markdown, { type Components } from "@rigelbuild/solid-markdown";

const components: Components = {
code(props) {
return <code data-tag={props.node?.tagName}>{props.children}</code>;
},
};

<Markdown components={components}>{"`example`"}</Markdown>;
```

## Migration

### Default import

Before:

```tsx
import { SolidMarkdown } from "@rigelbuild/solid-markdown";

<SolidMarkdown children={markdown} />;
```

## Rendering strategy
There's an extra option you can pass to the markdown component: `renderingStrategy: "memo" | "reconcile"`.
After:

The default value is `"memo"`, which means that the markdown parser will generate a new full AST tree each time (inside a `useMemo`), and use that.
As a consequence, the full DOM will be re-rendered, even the markdown nodes that haven't changed. (Should be fine 90% of the time).
```tsx
import Markdown from "@rigelbuild/solid-markdown";

Using `reconcile` will switch the strategy to using a solid store with the `reconcile` function (https://docs.solidjs.com/reference/store-utilities/reconcile). This will diff the previous and next markdown ASTs and only trigger re-renders for the parts that have changed.
This will help with cases like streaming partial content and updating the markdown gradually (see https://github.com/andi23rosca/solid-markdown/issues/32).
<Markdown>{markdown}</Markdown>;
```

### Wrapper ownership

Before:

```tsx
<SolidMarkdown renderingStrategy="reconcile" children={markdown} />;
<Markdown class="markdown-body">{markdown}</Markdown>;
```

After:

```tsx
<div class="markdown-body">
<Markdown>{markdown}</Markdown>
</div>
```

### URL transforms

Before:

```tsx
<Markdown transformLinkUri={(href) => href} transformImageUri={(src) => src}>
{markdown}
</Markdown>;
```

After:

```tsx
<Markdown
urlTransform={(url, key) => {
if (key === "href") return url;
if (key === "src") return url;
return url;
}}
>
{markdown}
</Markdown>;
```

### Removed and deprecated behavior

- `SolidMarkdown` is gone. Use the default export instead.
- Wrapper props such as `class` and `className` are gone. Wrap `Markdown` in your own element.
- Legacy pre-v9 props now throw at runtime instead of being silently accepted. This includes deprecated names such as `source`, `plugins`, `renderers`, `allowNode`, `allowedTypes`, `disallowedTypes`, `transformLinkUri`, `transformImageUri`, `linkTarget`, and the old source-position props.
- `urlTransform` replaces `transformLinkUri` and `transformImageUri`.
- `renderingStrategy="memo" | "reconcile"` is a supported, Solid-specific prop on the synchronous `Markdown` export. This fork keeps it un-deprecated: `"reconcile"` is load-bearing for streaming DOM stability (it rebuilds the subtree each tick so growing content stays consistent), and consumers rely on it permanently.

## Testing and status

Local verification for this port currently runs through:

```bash
bun run lint
bun run typecheck
bun run test
bun run build
```

## TODO
Current status:

- [ ] Port unit tests from from original library
- Sync rendering is supported through `Markdown`.
- Async unified plugins are supported on the server through `MarkdownAsync`.
- Async unified plugins are supported on the client through `MarkdownResource`.
- SSR and DOM behavior are covered by the package test suite.
5 changes: 4 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"correctness": {
"noChildrenProp": "off"
}
}
},
"overrides": [
Expand Down
Loading