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
61 changes: 61 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This is a **monorepo** containing the Comark Markdown parser, document model, pl
│ ├── comark-react/ # React renderer + plugins (@comark/react)
│ ├── comark-svelte/ # Svelte renderer + plugins (@comark/svelte)
│ ├── comark-angular/ # Angular renderer + plugins (@comark/angular)
│ ├── comark-prose/ # Framework-agnostic prose components (@comark/prose)
│ └── comark-nuxt/ # Nuxt module (@comark/nuxt)
├── examples/ # Example applications
│ ├── 1.frameworks/ # Framework examples (Nuxt, Next.js, Astro, SvelteKit, ...)
Expand Down Expand Up @@ -321,6 +322,58 @@ Uses Vitest with two test projects:
</svelte:boundary>
```

## Package: @comark/prose

Located at `packages/comark-prose/`. Framework-agnostic prose components: a plugin that lowers component tags (callouts, tabs, code groups, steps, accordions, GFM alerts) plus structural elements (heading anchors, table scroll wrappers, code copy buttons) into plain HTML at parse time, working with every renderer.

```
packages/comark-prose/
├── src/
│ ├── index.ts # prose() plugin (elements + components lowering passes)
│ ├── types.ts # ProseOptions and related types
│ ├── utils.ts # attr/class helpers
│ ├── lower/ # One module per lowering (callout, tabs, code-group, ...)
│ ├── client/
│ │ ├── index.ts # register() + ProseTabsElement, ProseCopyElement
│ │ ├── tabs.ts # <prose-tabs> custom element (ARIA tabs, keyboard, sync)
│ │ ├── copy.ts # <prose-copy> custom element (clipboard)
│ │ └── register.ts # Side-effect entry
│ └── styles/ # CSS sources (tokens + per-component partials)
├── scripts/build-css.mjs # lightningcss bundling/minification into dist/
├── test/ # prose.test.ts (lowering) + client.test.ts (happy-dom)
└── package.json
```

### Exports

```json
{
".": "./dist/index.js",
"./client": "./dist/client/index.js",
"./client/register": "./dist/client/register.js",
"./components.css": "./dist/components.css",
"./typography.css": "./dist/typography.css",
"./styles/*.css": "./dist/styles/*.css"
}
```

### Usage

```typescript
import { parseMarkdown } from 'comark'
import prose from '@comark/prose'

const tree = await parseMarkdown(content, { plugins: [prose()] })
```

```typescript
// Client side (any framework, or none):
import '@comark/prose/components.css'
import '@comark/prose/client/register'
```

Interactivity is optional: without the client runtime, tabs render stacked, copy buttons stay hidden, and callouts/steps/accordions work with pure HTML + CSS.

## Package: @comark/angular

Located at `packages/comark-angular/`. Angular 17+ renderer with standalone components.
Expand Down Expand Up @@ -418,6 +471,14 @@ import html from 'comark/plugins/html' // default via registerDefa
import { markdownItComponents } from 'comark/plugins/components'
import { markdownItAttributes } from 'comark/plugins/attributes'

// Prose — lower docs components to plain HTML (framework-agnostic)
import prose from '@comark/prose'
import { register, ProseTabsElement, ProseCopyElement } from '@comark/prose/client'
// Side-effect registration + stylesheets:
// import '@comark/prose/client/register'
// import '@comark/prose/components.css'
// import '@comark/prose/typography.css'

// NOTE: All framework packages re-export every core plugin via their own subpath.
// Prefer the framework-specific path when using a framework renderer:
// @comark/vue/plugins/shiki, @comark/react/plugins/shiki, etc.
Expand Down
173 changes: 173 additions & 0 deletions docs/content/4.plugins/1.built-in/prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
---
title: Prose
description: Lower callouts, tabs, code groups, steps and accordions into plain HTML that works with any renderer, plus optional CSS and a tiny client runtime.
seo:
title: Prose Plugin
navigation:
icon: i-lucide-panels-top-left
links:
- label: Components Syntax
icon: i-lucide-component
to: /syntax/components
color: neutral
variant: soft
- label: HTML Renderer
icon: i-lucide-file-code
to: /rendering/html
color: neutral
variant: soft
---

The `@comark/prose` package makes docs components framework-agnostic. Its `prose` plugin lowers component tags — callouts, tabs, code groups, steps, accordions, GFM alerts — into plain, accessible HTML at parse time. Every renderer benefits: Vue, React, Svelte, Angular, and `@comark/html` string output.

The package has three independent layers:

- **Plugin**: rewrites the tree into semantic markup with `prose-*` classes.
- **CSS**: token-driven component styles, plus an optional typographic baseline.
- **Client runtime**: two dependency-free custom elements for the parts that need JavaScript. Callouts, steps, and tables are pure CSS; accordions use native `<details name>`.

## Installation

::code-group
```bash [pnpm]
pnpm add @comark/prose
```
```bash [npm]
npm install @comark/prose
```
::

## Usage

```typescript
import { parseMarkdown } from 'comark'
import prose from '@comark/prose'

const tree = await parseMarkdown(content, {
plugins: [prose()],
})
```

Add the stylesheets and register the custom elements once on the client:

```typescript
import '@comark/prose/components.css'
import '@comark/prose/typography.css' // optional element rhythm
import '@comark/prose/client/register'
```

With `@comark/html`, this produces a fully interactive docs page without any framework:

```typescript
import { renderHtml } from '@comark/html'
import prose from '@comark/prose'

const html = await renderHtml(content, { plugins: [prose()] })
```

::note
The plugin only transforms the tree. Rendering, styling, and interactivity stay decoupled, so you can adopt one layer at a time.
::

## What gets lowered

| Markdown | Output | JavaScript |
| --- | --- | --- |
| `::note`, `::tip`, `::warning`, `::caution`, `::callout{color}`, `> [!NOTE]` | `<div class="prose-callout" role="note" data-variant>` | none |
| `::tabs` with `::tab-item{label}` | `<prose-tabs>` with a WAI-ARIA tablist | tab switching, keyboard navigation, group sync |
| `::code-group` | same tabs markup, labelled by filename or language | same |
| `::steps{level}` | `<div class="prose-steps">` with CSS counters on child headings | none |
| `::accordion` with `::accordion-item{label}` | native `<details name>` group | none |
| Code fences | `<figure class="prose-pre">` with filename header and copy button | copy to clipboard |
| Headings `h2`–`h4` | content wrapped in `<a href="#id">` with a hash icon | none |
| Tables | wrapped in a horizontal scroll container | none |

Without the client runtime, tab panels render stacked so all content stays reachable, and copy buttons stay hidden (`prose-copy:not(:defined)`).

## Options

```typescript
prose({
elements: {
// Which heading levels get anchor links (default: h2-h4)
headingAnchors: { h2: true, h3: true, h4: true },
// Class string, element node, or false (default: inline hash SVG)
anchorIcon: 'i-lucide-hash',
// Table scroll container (default: <div class="prose-table">)
tableWrapper: { tag: 'div', class: 'prose-table' },
},
components: {
callout: true,
tabs: true,
codeGroup: true,
steps: true,
accordion: true,
// Copy button label, or false to disable
copy: { label: 'Copy code' },
},
})
```

Set `elements: false` or `components: false` to disable a whole pass. Set a single component key to `false` to keep that tag for a framework component instead — for example `components: { tabs: false }` when your Vue app renders `::tabs` with its own component.

### Class map for design systems

Bake utility classes into plain tags at parse time when your design system styles elements with classes instead of a stylesheet:

```typescript
prose({
classes: {
p: 'my-5 leading-7',
h2: 'text-2xl font-bold mt-12',
},
// Optional: tailwind-merge-style merging with author classes
mergeClass: (theme, author) => twMerge(theme, author as string),
})
```

### Per-tag transform

The `transform` option runs before the built-in lowerings. Return a node to replace, `false` to remove, or `undefined` to fall through:

```typescript
prose({
transform: {
note: (node) => ['mark', {}, ...node.slice(2)],
hr: () => false,
},
})
```

## Styling

`@comark/prose/components.css` styles the lowered markup. It is scoped to `.comark-content` (the wrapper class the renderers emit), uses zero-specificity `:where()` selectors so plain CSS and utilities override it, and stays append-stable for streaming: spacing flows through `margin-block-start` only, with no forward-looking selectors.

Three rhythm tokens drive everything, with color hooks based on `light-dark()`:

```css
:where(.comark-content) {
--prose-size: 1em; /* base font size */
--prose-leading: 1.75; /* line height */
--prose-flow: 1.25em; /* space between blocks */
}
```

- `@comark/prose/typography.css` adds an optional typographic baseline for plain elements. Skip it if you already use a prose stylesheet such as Tailwind Typography or shadcn Typeset — the lowered markup is plain HTML, so container-scoped systems work as-is.
- Individual partials are available under `@comark/prose/styles/*.css` for cherry-picking.
- Add `not-prose` to a subtree to opt it out of the typography baseline.

## Client runtime

The runtime registers two custom elements. It has no dependencies and is safe to import on the server:

```typescript
// Register everything (idempotent):
import '@comark/prose/client/register'

// Or selectively:
import { register } from '@comark/prose/client'
register({ tabs: true, copy: false })
```

- `<prose-tabs>` wires clicks, arrow-key navigation (WAI-APG, automatic activation), and group sync: instances sharing `::tabs{sync="pkg"}` follow each other's selected label, persisted in `localStorage`. Streamed panels are picked up automatically.
- `<prose-copy>` copies the code block text on click, flips `data-copied` for the icon swap, and announces the result in a live region.
14 changes: 14 additions & 0 deletions examples/3.plugins/html-prose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Comark HTML + Prose example

A fully interactive docs page with **no framework**: markdown is rendered to an HTML string with `@comark/html`, the `@comark/prose` plugin lowers callouts, tabs, code groups, steps and accordions to plain HTML, and one script tag registers the two custom elements that power tabs and copy buttons.

```bash
pnpm install
pnpm dev
```

## What to look at

- `src/main.ts` — `renderHtml(markdown, { plugins: [prose()] })` plus three imports: `components.css`, `typography.css`, and `client/register`.
- Disable JavaScript in your browser: tab panels render stacked, accordions and callouts keep working, copy buttons disappear.
- The tabs and the code group share `sync="pkg"` — switching one switches the other, persisted in `localStorage`.
21 changes: 21 additions & 0 deletions examples/3.plugins/html-prose/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Comark Prose — framework-free docs page</title>
</head>
<body>
<main
id="app"
class="comark-content"
></main>
<script
type="module"
src="/src/main.ts"
></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/3.plugins/html-prose/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "comark-html-prose",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@comark/html": "workspace:*",
"@comark/prose": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"vite": "catalog:"
}
}
Loading
Loading