From 7c05ef3b8a4af40398081529c736d0b872b9a4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 12:58:53 +0200 Subject: [PATCH 1/5] fix(comark): run default normalizer post hooks before user post hooks User plugin post hooks previously ran before the default plugins' post hooks, so tree-consuming plugins saw an un-normalized tree (e.g. GFM alerts were still plain blockquotes because `alert` had not rewritten them into `['blockquote', { as }]` yet). Post hooks now run defaults first, then user plugins. Registration order is unchanged: user plugins still come first so same-name entries override defaults via dedupePlugins, and a user override keeps the default's execution slot. `pre` hooks and `markdownItPlugins` keep their existing order. --- packages/comark/src/parse.ts | 14 ++++++++++- .../test/plugins/default-plugins.test.ts | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/comark/src/parse.ts b/packages/comark/src/parse.ts index 9fda91e9..f882d931 100644 --- a/packages/comark/src/parse.ts +++ b/packages/comark/src/parse.ts @@ -32,6 +32,9 @@ export { parseFrontmatter } from './internal/frontmatter.ts' // Re-export plugin utilities export { defineComarkPlugin } from './utils/helpers.ts' +/** Names of the default plugins registered by `registerDefaultPlugins` (see below). */ +const DEFAULT_PLUGIN_NAMES = new Set(['frontmatter', 'html', 'alert', 'task-list', 'components', 'attributes']) + /** * Creates a parser function for Comark content. * @@ -95,6 +98,15 @@ export function createMarkdownParser plugins.some((plugin) => plugin.name === name) + // Default normalizer `post` hooks run before user `post` hooks so user plugins always see + // the normalized tree (e.g. `alert` has rewritten `> [!note]` into `['blockquote', { as }]`). + // A user plugin that overrides a default by name keeps that default's slot. `pre` hooks and + // `markdownItPlugins` keep registration order: user plugins first. + const postPlugins = [ + ...plugins.filter((plugin) => DEFAULT_PLUGIN_NAMES.has(plugin.name)), + ...plugins.filter((plugin) => !DEFAULT_PLUGIN_NAMES.has(plugin.name)), + ] + const parser = new MarkdownExit({ linkify: options.linkify ?? true }).enable(['table', 'strikethrough']) for (const plugin of plugins) { @@ -206,7 +218,7 @@ export function createMarkdownParser plugin.post!(state as ComarkParsePostState)) } diff --git a/packages/comark/test/plugins/default-plugins.test.ts b/packages/comark/test/plugins/default-plugins.test.ts index 2db2dbaf..5a9f1739 100644 --- a/packages/comark/test/plugins/default-plugins.test.ts +++ b/packages/comark/test/plugins/default-plugins.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { ComarkPlugin } from '../../src/types' import { parseMarkdown } from '../../src/parse' import attributes from '../../src/plugins/attributes' import components from '../../src/plugins/components' @@ -121,6 +122,29 @@ describe('default plugin options', () => { }) }) + describe('post hook ordering', () => { + it('runs default normalizer post hooks before user post hooks', async () => { + let seen: unknown + const probe: ComarkPlugin = { + name: 'probe', + post(state) { + seen = structuredClone(state.tree.nodes) + }, + } + await parseMarkdown('> [!NOTE]\n> hi', { plugins: [probe] }) + // `alert` has already rewritten the blockquote when the user post hook runs. + expect(seen).toEqual([['blockquote', { as: 'note' }, 'hi']]) + }) + + it('keeps the default slot for a user plugin that overrides a default by name', async () => { + const order: string[] = [] + const probe: ComarkPlugin = { name: 'probe', post: () => void order.push('probe') } + const alertOverride: ComarkPlugin = { name: 'alert', post: () => void order.push('alert-override') } + await parseMarkdown('> [!NOTE]\n> hi', { plugins: [probe, alertOverride] }) + expect(order).toEqual(['alert-override', 'probe']) + }) + }) + describe('user plugin override', () => { it('keeps an explicit components plugin active with registerDefaultPlugins: false', async () => { const tree = await parseMarkdown('::alert\nContent', { From 4c450eb47b044664c02d9325f781216cd26c1b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 13:29:47 +0200 Subject: [PATCH 2/5] fix(comark): keep literal aria-* attribute values in HTML output htmlAttributes collapsed any 'true' value to a bare attribute. That is correct for HTML boolean attributes (disabled, hidden) but wrong for ARIA: aria-* attributes are enumerated, and an empty value means absent/false (aria-hidden='' is not hidden, aria-selected='' is not selected). aria-* values now stay literal, including boolean false. --- packages/comark-html/test/string.test.ts | 17 +++++++++++++++++ .../comark/src/internal/stringify/attributes.ts | 14 +++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/comark-html/test/string.test.ts b/packages/comark-html/test/string.test.ts index fe0ba0bb..6b0a996d 100644 --- a/packages/comark-html/test/string.test.ts +++ b/packages/comark-html/test/string.test.ts @@ -207,4 +207,21 @@ name: Ada expect(html).toContain('title="frontmatter.name"') }) }) + + describe('aria attributes', () => { + it('keeps literal aria-* values instead of collapsing to bare attributes', async () => { + const html = await renderHtmlFromDocument({ + nodes: [ + ['button', { 'aria-selected': 'true', 'aria-hidden': true, 'aria-expanded': false, disabled: 'true' }, 'Tab'], + ], + }) + // `aria-hidden=""` means absent/false, so values must stay literal. + expect(html).toContain('aria-selected="true"') + expect(html).toContain('aria-hidden="true"') + expect(html).toContain('aria-expanded="false"') + // Regular boolean attributes still collapse. + expect(html).toContain(' disabled') + expect(html).not.toContain('disabled=') + }) + }) }) diff --git a/packages/comark/src/internal/stringify/attributes.ts b/packages/comark/src/internal/stringify/attributes.ts index 557cf70f..f9d9472b 100644 --- a/packages/comark/src/internal/stringify/attributes.ts +++ b/packages/comark/src/internal/stringify/attributes.ts @@ -244,9 +244,13 @@ export function htmlAttributes(attributes: Record) { const key = rawKey.startsWith(':') ? rawKey.slice(1) : rawKey if (!SAFE_ATTR_NAME.test(key)) continue + // ARIA attributes are enumerated, not boolean: `aria-hidden=""` means absent/false, + // so `true`/`false` values must stay literal instead of collapsing to bare attributes. + const isAria = key.startsWith('aria-') + if (rawKey.startsWith(':')) { if (value === 'true') { - parts.push(key) + parts.push(isAria ? `${key}="true"` : key) continue } if (typeof value === 'object' && value !== null) { @@ -258,10 +262,14 @@ export function htmlAttributes(attributes: Record) { } if (value === true || value === 'true') { - parts.push(key) + parts.push(isAria ? `${key}="true"` : key) + continue + } + if (value === false) { + if (isAria) parts.push(`${key}="false"`) continue } - if (value === false || value === null || value === undefined) continue + if (value === null || value === undefined) continue if (typeof value === 'object') { parts.push(`${key}="${escapeHtml(JSON.stringify(value))}"`) From 88a27a92750276511515a8842fd4698e7c852107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Wed, 26 Aug 2026 13:30:11 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(prose):=20add=20@comark/prose=20?= =?UTF-8?q?=E2=80=94=20framework-agnostic=20prose=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package lowering docs components into plain HTML at parse time, so every renderer benefits (Vue, React, Svelte, Angular, @comark/html): - callouts (::note family, ::callout{color}, GFM alerts) ->
with CSS mask icons - ::tabs / ::code-group -> WAI-ARIA tablist; panels render stacked without JavaScript - ::accordion -> native
groups (exclusive by default) - ::steps -> CSS counters on child headings - code fences -> figure with filename header + button - heading anchor links (h2-h4) and table scroll wrappers - optional classes/mergeClass map for utility-class design systems and a per-tag transform escape hatch Styling and interactivity are decoupled layers: - components.css / typography.css: token-driven (size/leading/flow), :where() scoped to .comark-content, append-stable for streaming; partials exported under styles/*.css (built with lightningcss) - client runtime: two dependency-free custom elements ( with keyboard nav + localStorage group sync, ), safe to import on the server Includes docs page, a framework-free example (examples/3.plugins/ html-prose), 27 tests, and the bundle snapshot update. --- AGENTS.md | 61 ++ docs/content/4.plugins/1.built-in/prose.md | 173 ++++ examples/3.plugins/html-prose/README.md | 14 + examples/3.plugins/html-prose/index.html | 21 + examples/3.plugins/html-prose/package.json | 18 + examples/3.plugins/html-prose/src/main.ts | 100 ++ examples/3.plugins/html-prose/src/style.css | 16 + examples/3.plugins/html-prose/tsconfig.json | 14 + examples/3.plugins/html-prose/vite.config.ts | 3 + packages/comark-prose/.release-it.json | 38 + packages/comark-prose/README.md | 136 +++ packages/comark-prose/package.json | 62 ++ packages/comark-prose/scripts/build-css.mjs | 32 + packages/comark-prose/src/client/copy.ts | 45 + packages/comark-prose/src/client/index.ts | 33 + packages/comark-prose/src/client/register.ts | 4 + packages/comark-prose/src/client/tabs.ts | 124 +++ packages/comark-prose/src/index.ts | 135 +++ packages/comark-prose/src/lower/accordion.ts | 51 + packages/comark-prose/src/lower/callout.ts | 52 + packages/comark-prose/src/lower/code-group.ts | 54 ++ packages/comark-prose/src/lower/copy.ts | 37 + packages/comark-prose/src/lower/headings.ts | 65 ++ packages/comark-prose/src/lower/steps.ts | 17 + packages/comark-prose/src/lower/table.ts | 24 + packages/comark-prose/src/lower/tabs.ts | 94 ++ .../comark-prose/src/styles/accordion.css | 55 ++ packages/comark-prose/src/styles/anchors.css | 25 + packages/comark-prose/src/styles/callout.css | 87 ++ .../comark-prose/src/styles/components.css | 19 + packages/comark-prose/src/styles/copy.css | 91 ++ packages/comark-prose/src/styles/steps.css | 43 + packages/comark-prose/src/styles/table.css | 9 + packages/comark-prose/src/styles/tabs.css | 85 ++ packages/comark-prose/src/styles/tokens.css | 43 + .../comark-prose/src/styles/typography.css | 100 ++ packages/comark-prose/src/types.ts | 86 ++ packages/comark-prose/src/utils.ts | 59 ++ packages/comark-prose/test/client.test.ts | 122 +++ packages/comark-prose/test/prose.test.ts | 292 ++++++ packages/comark-prose/tsconfig.json | 20 + pnpm-lock.yaml | 918 ++++-------------- pnpm-workspace.yaml | 2 + test/bundle.test.ts | 3 +- 44 files changed, 2766 insertions(+), 716 deletions(-) create mode 100644 docs/content/4.plugins/1.built-in/prose.md create mode 100644 examples/3.plugins/html-prose/README.md create mode 100644 examples/3.plugins/html-prose/index.html create mode 100644 examples/3.plugins/html-prose/package.json create mode 100644 examples/3.plugins/html-prose/src/main.ts create mode 100644 examples/3.plugins/html-prose/src/style.css create mode 100644 examples/3.plugins/html-prose/tsconfig.json create mode 100644 examples/3.plugins/html-prose/vite.config.ts create mode 100644 packages/comark-prose/.release-it.json create mode 100644 packages/comark-prose/README.md create mode 100644 packages/comark-prose/package.json create mode 100644 packages/comark-prose/scripts/build-css.mjs create mode 100644 packages/comark-prose/src/client/copy.ts create mode 100644 packages/comark-prose/src/client/index.ts create mode 100644 packages/comark-prose/src/client/register.ts create mode 100644 packages/comark-prose/src/client/tabs.ts create mode 100644 packages/comark-prose/src/index.ts create mode 100644 packages/comark-prose/src/lower/accordion.ts create mode 100644 packages/comark-prose/src/lower/callout.ts create mode 100644 packages/comark-prose/src/lower/code-group.ts create mode 100644 packages/comark-prose/src/lower/copy.ts create mode 100644 packages/comark-prose/src/lower/headings.ts create mode 100644 packages/comark-prose/src/lower/steps.ts create mode 100644 packages/comark-prose/src/lower/table.ts create mode 100644 packages/comark-prose/src/lower/tabs.ts create mode 100644 packages/comark-prose/src/styles/accordion.css create mode 100644 packages/comark-prose/src/styles/anchors.css create mode 100644 packages/comark-prose/src/styles/callout.css create mode 100644 packages/comark-prose/src/styles/components.css create mode 100644 packages/comark-prose/src/styles/copy.css create mode 100644 packages/comark-prose/src/styles/steps.css create mode 100644 packages/comark-prose/src/styles/table.css create mode 100644 packages/comark-prose/src/styles/tabs.css create mode 100644 packages/comark-prose/src/styles/tokens.css create mode 100644 packages/comark-prose/src/styles/typography.css create mode 100644 packages/comark-prose/src/types.ts create mode 100644 packages/comark-prose/src/utils.ts create mode 100644 packages/comark-prose/test/client.test.ts create mode 100644 packages/comark-prose/test/prose.test.ts create mode 100644 packages/comark-prose/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index ea0ba40c..81ef734e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, ...) @@ -321,6 +322,58 @@ Uses Vitest with two test projects: ``` +## 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 # custom element (ARIA tabs, keyboard, sync) +│ │ ├── copy.ts # 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. @@ -412,6 +465,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. diff --git a/docs/content/4.plugins/1.built-in/prose.md b/docs/content/4.plugins/1.built-in/prose.md new file mode 100644 index 00000000..d563b15a --- /dev/null +++ b/docs/content/4.plugins/1.built-in/prose.md @@ -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 `
`. + +## 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]` | `
` | none | +| `::tabs` with `::tab-item{label}` | `` with a WAI-ARIA tablist | tab switching, keyboard navigation, group sync | +| `::code-group` | same tabs markup, labelled by filename or language | same | +| `::steps{level}` | `
` with CSS counters on child headings | none | +| `::accordion` with `::accordion-item{label}` | native `
` group | none | +| Code fences | `
` with filename header and copy button | copy to clipboard | +| Headings `h2`–`h4` | content wrapped in `` 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:
) + 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 }) +``` + +- `` 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. +- `` copies the code block text on click, flips `data-copied` for the icon swap, and announces the result in a live region. diff --git a/examples/3.plugins/html-prose/README.md b/examples/3.plugins/html-prose/README.md new file mode 100644 index 00000000..f99e5802 --- /dev/null +++ b/examples/3.plugins/html-prose/README.md @@ -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`. diff --git a/examples/3.plugins/html-prose/index.html b/examples/3.plugins/html-prose/index.html new file mode 100644 index 00000000..2f605175 --- /dev/null +++ b/examples/3.plugins/html-prose/index.html @@ -0,0 +1,21 @@ + + + + + + Comark Prose — framework-free docs page + + +
+ + + diff --git a/examples/3.plugins/html-prose/package.json b/examples/3.plugins/html-prose/package.json new file mode 100644 index 00000000..bd031ba0 --- /dev/null +++ b/examples/3.plugins/html-prose/package.json @@ -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:" + } +} diff --git a/examples/3.plugins/html-prose/src/main.ts b/examples/3.plugins/html-prose/src/main.ts new file mode 100644 index 00000000..354b7192 --- /dev/null +++ b/examples/3.plugins/html-prose/src/main.ts @@ -0,0 +1,100 @@ +import { renderHtml } from '@comark/html' +import prose from '@comark/prose' +import '@comark/prose/components.css' +import '@comark/prose/typography.css' +import '@comark/prose/client/register' +import './style.css' + +const SAMPLE = `# Comark Prose + +A complete docs page rendered to **plain HTML** with \`@comark/html\` — no framework. +Tabs, code groups and copy buttons come from two tiny custom elements; everything else +is pure HTML and CSS. + +## Callouts + +::note +Callouts are plain \`
\` elements. Zero JavaScript. +:: + +::warning{title="Careful"} +They support variants, titles and GFM alert syntax too. +:: + +> [!TIP] +> This one was written as a GFM alert (\`> [!TIP]\`). + +## Tabs + +:::tabs{sync="pkg"} + ::tab-item{label="pnpm"} + Install with \`pnpm add @comark/prose\`. + :: + ::tab-item{label="npm"} + Install with \`npm install @comark/prose\`. + :: +::: + +## Code group + +Both groups share the \`sync="pkg"\` key with the tabs above — switch one and the others follow. + +::code-group{sync="pkg"} +~~~bash [pnpm] +pnpm add @comark/prose +~~~ +~~~bash [npm] +npm install @comark/prose +~~~ +:: + +## Code block with copy button + +~~~ts [render.ts] +import { renderHtml } from '@comark/html' +import prose from '@comark/prose' + +const html = await renderHtml(markdown, { plugins: [prose()] }) +~~~ + +## Steps + +::steps{level="3"} + +### Install the package + +Add \`@comark/prose\` to your project. + +### Register the plugin + +Pass \`prose()\` to \`parseMarkdown\` or \`renderHtml\`. + +### Ship it + +Style with the package CSS or bring your own. + +:: + +## Accordion + +:::accordion + ::accordion-item{label="Does this need a framework?"} + No. This whole page is an HTML string plus one script tag. + :: + ::accordion-item{label="What about accessibility?"} + Tabs follow the WAI-ARIA tabs pattern, accordions are native \`
\`, + and copy results are announced in a live region. + :: +::: + +## Table + +| Layer | Import | Needed for | +| --- | --- | --- | +| Plugin | \`@comark/prose\` | lowering components to HTML | +| CSS | \`@comark/prose/components.css\` | styling | +| Client | \`@comark/prose/client/register\` | tabs + copy interactivity | +` + +const app = document.querySelector('#app')! +app.innerHTML = await renderHtml(SAMPLE, { plugins: [prose()] }) diff --git a/examples/3.plugins/html-prose/src/style.css b/examples/3.plugins/html-prose/src/style.css new file mode 100644 index 00000000..5ebf8c77 --- /dev/null +++ b/examples/3.plugins/html-prose/src/style.css @@ -0,0 +1,16 @@ +:root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +body { + margin: 0; + background: light-dark(#fff, #111); + color: light-dark(#1a1a1a, #ededed); +} + +#app { + max-width: 44rem; + margin: 0 auto; + padding: 3rem 1.5rem 6rem; +} diff --git a/examples/3.plugins/html-prose/tsconfig.json b/examples/3.plugins/html-prose/tsconfig.json new file mode 100644 index 00000000..100ec07b --- /dev/null +++ b/examples/3.plugins/html-prose/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["esnext", "DOM", "DOM.Iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src/**/*"] +} diff --git a/examples/3.plugins/html-prose/vite.config.ts b/examples/3.plugins/html-prose/vite.config.ts new file mode 100644 index 00000000..4f1b25a0 --- /dev/null +++ b/examples/3.plugins/html-prose/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite' + +export default defineConfig({}) diff --git a/packages/comark-prose/.release-it.json b/packages/comark-prose/.release-it.json new file mode 100644 index 00000000..077665c7 --- /dev/null +++ b/packages/comark-prose/.release-it.json @@ -0,0 +1,38 @@ +{ + "git": { + "commitMessage": "chore(prose): release v${version}", + "tagName": "@comark/prose@${version}", + "tagAnnotation": "@comark/prose v${version}", + "requireCleanWorkingDir": true + }, + "github": { + "release": true, + "releaseName": "@comark/prose v${version}" + }, + "npm": { + "publish": true, + "publishPath": ".", + "publishPackageManager": "pnpm", + "publishArgs": ["--no-git-checks"] + }, + "plugins": { + "@release-it/conventional-changelog": { + "ignoreRecommendedBump": true, + "preset": { + "name": "conventionalcommits", + "types": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" } + ] + }, + "infile": "CHANGELOG.md", + "gitRawCommitsOpts": { + "path": "." + } + } + }, + "hooks": { + "before:release": ["pnpm run build"] + } +} diff --git a/packages/comark-prose/README.md b/packages/comark-prose/README.md new file mode 100644 index 00000000..13338eb9 --- /dev/null +++ b/packages/comark-prose/README.md @@ -0,0 +1,136 @@ +Comark banner + +# @comark/prose + +[![npm version](https://img.shields.io/npm/v/@comark/prose?color=black)](https://npmx.dev/@comark/prose) +[![npm downloads](https://img.shields.io/npm/dm/@comark/prose?color=black)](https://npm.chart.dev/@comark/prose) +[![CI](https://img.shields.io/github/actions/workflow/status/comarkdown/comark/ci.yml?branch=main&color=black)](https://github.com/comarkdown/comark/actions/workflows/ci.yml) +[![Documentation](https://img.shields.io/badge/Documentation-black?logo=readme&logoColor=white)](https://comark.dev/plugins/prose) +[![license](https://img.shields.io/github/license/comarkdown/comark?color=black)](https://github.com/comarkdown/comark/blob/main/LICENSE) + +Framework-agnostic prose components for [Comark](https://comark.dev). The `prose` plugin lowers docs components — callouts, tabs, code groups, steps, accordions, GFM alerts — into plain HTML at parse time, so every renderer benefits: Vue, React, Svelte, Angular, and `@comark/html` string output. + +Styling and interactivity are decoupled layers you opt into: + +- **Plugin** (`@comark/prose`) — rewrites the tree into semantic, accessible markup. +- **CSS** (`@comark/prose/components.css`) — token-driven styles, `:where()` scoped, streaming-stable. Or bring your own prose stylesheet. +- **Client runtime** (`@comark/prose/client`) — two dependency-free custom elements (``, ``) for the parts that need JavaScript. Everything else works without it: callouts, steps, and tables are pure CSS, and accordions use native `
`. + +## Installation + +```bash +npm install @comark/prose +# or +pnpm add @comark/prose +``` + +## Usage + +```ts +import { parseMarkdown } from 'comark' +import prose from '@comark/prose' + +const tree = await parseMarkdown(markdown, { plugins: [prose()] }) +``` + +Add the stylesheets and register the custom elements once on the client: + +```ts +import '@comark/prose/components.css' +import '@comark/prose/typography.css' // optional element rhythm +import '@comark/prose/client/register' +``` + +Or with plain HTML and `@comark/html` — a fully interactive docs page with no framework: + +```ts +import { renderHtml } from '@comark/html' +import prose from '@comark/prose' + +const html = await renderHtml(markdown, { plugins: [prose()] }) +``` + +```html + +
+ +``` + +## What gets lowered + +| Markdown | Output | JavaScript | +| --- | --- | --- | +| `::note`, `::tip`, `::warning`, `::caution`, `::callout{color}`, `> [!NOTE]` | `
` | none | +| `::tabs` + `::tab-item{label}` | `` with a WAI-ARIA tablist | tab switching, keyboard nav, group sync | +| `::code-group` | same tabs markup, labelled by filename/language | same | +| `::steps{level}` | `
`, CSS counters on child headings | none | +| `::accordion` + `::accordion-item{label}` | native `
` group | none | +| Code fences | `
` with filename header and copy button | copy to clipboard | +| Headings (`h2`–`h4`) | content wrapped in `` with a hash icon | none | +| Tables | wrapped in a horizontal scroll container | none | + +Without the client runtime, tab panels render stacked (all content stays reachable) and copy buttons stay hidden. + +## Styling options + +Three ways to style the output — pick one: + +1. **Package CSS**: `@comark/prose/components.css` (components) and `@comark/prose/typography.css` (element rhythm). Both are scoped to `.comark-content`, use zero-specificity `:where()` selectors, and derive spacing from three tokens: `--prose-size`, `--prose-leading`, `--prose-flow`. Individual partials are available under `@comark/prose/styles/*.css`. +2. **Any prose stylesheet**: the lowered markup is plain HTML, so container-scoped systems like [Tailwind Typography](https://github.com/tailwindlabs/tailwindcss-typography) or [shadcn Typeset](https://ui.shadcn.com/docs/typeset) work as-is — keep `components.css` for the component chrome and skip `typography.css`. +3. **Utility class map**: bake design-system classes into plain tags at parse time: + +```ts +prose({ + classes: { + p: 'my-5 leading-7', + h2: 'text-2xl font-bold mt-12', + }, + // optional: tailwind-merge-style class merging + mergeClass: (theme, author) => twMerge(theme, author as string), +}) +``` + +## Options + +```ts +prose({ + elements: { + headingAnchors: { h2: true, h3: true, h4: true }, // or boolean + anchorIcon: 'i-lucide-hash', // class string, element node, or false + tableWrapper: { tag: 'div', class: 'prose-table' }, // or false + }, + components: { + callout: true, + tabs: true, + codeGroup: true, + steps: true, + accordion: true, + copy: { label: 'Copy code' }, // or boolean + }, + // per-tag escape hatch, runs before the built-ins + transform: { + hr: () => false, // remove + }, +}) +``` + +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. + +## Client runtime + +```ts +// Register everything (safe on the server, idempotent): +import '@comark/prose/client/register' + +// Or selectively: +import { register } from '@comark/prose/client' +register({ tabs: true, copy: false }) +``` + +`` supports cross-instance sync: give tabs a key (`::tabs{sync="pkg"}`) and every instance with the same key follows the selected label, persisted in `localStorage`. + +## License + +[MIT](https://github.com/comarkdown/comark/blob/main/LICENSE) diff --git a/packages/comark-prose/package.json b/packages/comark-prose/package.json new file mode 100644 index 00000000..e38e896c --- /dev/null +++ b/packages/comark-prose/package.json @@ -0,0 +1,62 @@ +{ + "name": "@comark/prose", + "version": "0.1.0", + "description": "Framework-agnostic prose components for Comark: lowers callouts, tabs, code groups, steps and accordions into plain HTML, with themeable CSS and a tiny custom-elements runtime.", + "keywords": [ + "callout", + "comark", + "css", + "custom-elements", + "markdown", + "mdc", + "prose", + "tabs", + "typography" + ], + "homepage": "https://comark.dev/plugins/prose", + "bugs": { + "url": "https://github.com/comarkdown/comark/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/comarkdown/comark.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": [ + "*.css", + "./dist/client/register.js" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./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" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "stub": "node ../../scripts/stub.mjs && node scripts/build-css.mjs", + "build": "tsc && node scripts/build-css.mjs", + "dev": "tsc --watch", + "test": "vitest run", + "prepack": "tsc && node scripts/build-css.mjs", + "release": "release-it" + }, + "dependencies": { + "comark": "workspace:*" + }, + "devDependencies": { + "happy-dom": "catalog:", + "lightningcss": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/comark-prose/scripts/build-css.mjs b/packages/comark-prose/scripts/build-css.mjs new file mode 100644 index 00000000..ff9975ba --- /dev/null +++ b/packages/comark-prose/scripts/build-css.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// Builds the package stylesheets with lightningcss: +// - dist/components.css and dist/typography.css: @imports inlined, minified +// - dist/styles/*.css: each partial built standalone, minified (for cherry-picking) + +import { mkdirSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { bundle } from 'lightningcss' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const srcDir = join(root, 'src/styles') +const distDir = join(root, 'dist') + +// No targets on purpose: the source is modern CSS (light-dark(), color-mix(), masks) +// and must not be downleveled. lightningcss inlines @imports and minifies. +const entries = ['components.css', 'typography.css'] +const partials = readdirSync(srcDir).filter((file) => file.endsWith('.css') && !entries.includes(file)) + +mkdirSync(join(distDir, 'styles'), { recursive: true }) + +for (const entry of entries) { + const { code } = bundle({ filename: join(srcDir, entry), minify: true }) + writeFileSync(join(distDir, entry), code) +} + +for (const file of partials) { + const { code } = bundle({ filename: join(srcDir, file), minify: true }) + writeFileSync(join(distDir, 'styles', file), code) +} + +console.log(`[css] @comark/prose: ${entries.length} bundles + ${partials.length} partials`) diff --git a/packages/comark-prose/src/client/copy.ts b/packages/comark-prose/src/client/copy.ts new file mode 100644 index 00000000..2768f572 --- /dev/null +++ b/packages/comark-prose/src/client/copy.ts @@ -0,0 +1,45 @@ +const COPIED_TIMEOUT = 2000 + +/** + * `` — copies the text of the sibling code block. + * + * Wraps the button emitted by the `prose` plugin. Copies the `
` text content at
+ * click time (so highlighted markup is fine), flips `data-copied` for the stylesheet to
+ * swap the icon, and announces the result in a visually hidden live region.
+ */
+export class ProseCopyElement extends HTMLElement {
+  private timeout: ReturnType | undefined
+  private status: HTMLElement | undefined
+
+  connectedCallback(): void {
+    this.addEventListener('click', this)
+    if (!this.status) {
+      const status = document.createElement('span')
+      status.setAttribute('role', 'status')
+      status.className = 'prose-sr-only'
+      this.append(status)
+      this.status = status
+    }
+  }
+
+  disconnectedCallback(): void {
+    this.removeEventListener('click', this)
+    if (this.timeout) clearTimeout(this.timeout)
+  }
+
+  handleEvent(event: Event): void {
+    if (!(event.target as HTMLElement | null)?.closest('button')) return
+    const pre = (this.closest('.prose-pre') ?? this.parentElement)?.querySelector('pre')
+    if (!pre) return
+
+    navigator.clipboard.writeText(pre.textContent ?? '').then(() => {
+      this.setAttribute('data-copied', '')
+      if (this.status) this.status.textContent = 'Copied'
+      if (this.timeout) clearTimeout(this.timeout)
+      this.timeout = setTimeout(() => {
+        this.removeAttribute('data-copied')
+        if (this.status) this.status.textContent = ''
+      }, COPIED_TIMEOUT)
+    })
+  }
+}
diff --git a/packages/comark-prose/src/client/index.ts b/packages/comark-prose/src/client/index.ts
new file mode 100644
index 00000000..f2acc884
--- /dev/null
+++ b/packages/comark-prose/src/client/index.ts
@@ -0,0 +1,33 @@
+import { ProseCopyElement } from './copy.ts'
+import { ProseTabsElement } from './tabs.ts'
+
+export { ProseCopyElement, ProseTabsElement }
+
+export interface RegisterOptions {
+  /** Register ``. @default true */
+  tabs?: boolean
+  /** Register ``. @default true */
+  copy?: boolean
+}
+
+/**
+ * Registers the `@comark/prose` custom elements. Safe to call multiple times and on the
+ * server (no-op without `customElements`).
+ *
+ * @example
+ * ```ts
+ * import { register } from '@comark/prose/client'
+ * register()
+ * // or as a side-effect entry:
+ * // import '@comark/prose/client/register'
+ * ```
+ */
+export function register(options: RegisterOptions = {}): void {
+  if (typeof customElements === 'undefined') return
+  if (options.tabs !== false && !customElements.get('prose-tabs')) {
+    customElements.define('prose-tabs', ProseTabsElement)
+  }
+  if (options.copy !== false && !customElements.get('prose-copy')) {
+    customElements.define('prose-copy', ProseCopyElement)
+  }
+}
diff --git a/packages/comark-prose/src/client/register.ts b/packages/comark-prose/src/client/register.ts
new file mode 100644
index 00000000..c1b7c7c7
--- /dev/null
+++ b/packages/comark-prose/src/client/register.ts
@@ -0,0 +1,4 @@
+import { register } from './index.ts'
+
+// Side-effect entry: `import '@comark/prose/client/register'`
+register()
diff --git a/packages/comark-prose/src/client/tabs.ts b/packages/comark-prose/src/client/tabs.ts
new file mode 100644
index 00000000..26d7e62d
--- /dev/null
+++ b/packages/comark-prose/src/client/tabs.ts
@@ -0,0 +1,124 @@
+const STORAGE_PREFIX = 'prose-tabs:'
+const SYNC_EVENT = 'prose-tabs:sync'
+
+/**
+ * `` — upgrades the ARIA tablist markup emitted by the `prose` plugin.
+ *
+ * Wires clicks, WAI-APG keyboard navigation (arrows, Home/End, automatic activation),
+ * and cross-instance group sync: instances sharing a `data-sync` key follow each other's
+ * selection by tab label, persisted in `localStorage`.
+ *
+ * Works with streamed content: a MutationObserver rebinds when panels are appended.
+ * Without this element defined, the stylesheet reveals all panels stacked.
+ */
+export class ProseTabsElement extends HTMLElement {
+  private observer: MutationObserver | undefined
+  private syncListener: ((event: Event) => void) | undefined
+
+  connectedCallback(): void {
+    this.addEventListener('click', this)
+    this.addEventListener('keydown', this)
+
+    this.observer = new MutationObserver(() => this.applyStoredSync())
+    this.observer.observe(this, { childList: true, subtree: true })
+
+    const key = this.syncKey
+    if (key) {
+      this.syncListener = (event: Event) => {
+        const detail = (event as CustomEvent<{ key: string; label: string }>).detail
+        if (detail.key === key) this.selectByLabel(detail.label)
+      }
+      window.addEventListener(SYNC_EVENT, this.syncListener)
+    }
+    this.applyStoredSync()
+  }
+
+  disconnectedCallback(): void {
+    this.removeEventListener('click', this)
+    this.removeEventListener('keydown', this)
+    this.observer?.disconnect()
+    if (this.syncListener) window.removeEventListener(SYNC_EVENT, this.syncListener)
+  }
+
+  handleEvent(event: Event): void {
+    if (event.type === 'click') this.onClick(event as MouseEvent)
+    else if (event.type === 'keydown') this.onKeydown(event as KeyboardEvent)
+  }
+
+  private get syncKey(): string | null {
+    return this.getAttribute('data-sync')
+  }
+
+  private get tabs(): HTMLElement[] {
+    return Array.from(this.querySelectorAll(':scope > [role="tablist"] > [role="tab"]'))
+  }
+
+  private onClick(event: MouseEvent): void {
+    const tab = (event.target as HTMLElement | null)?.closest('[role="tab"]')
+    if (!tab || !this.contains(tab)) return
+    this.select(this.tabs.indexOf(tab), { store: true })
+  }
+
+  private onKeydown(event: KeyboardEvent): void {
+    const tab = (event.target as HTMLElement | null)?.closest('[role="tab"]')
+    if (!tab || !this.contains(tab)) return
+
+    const tabs = this.tabs
+    const current = tabs.indexOf(tab)
+    let next: number
+    if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (current + 1) % tabs.length
+    else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (current - 1 + tabs.length) % tabs.length
+    else if (event.key === 'Home') next = 0
+    else if (event.key === 'End') next = tabs.length - 1
+    else return
+
+    event.preventDefault()
+    this.select(next, { focus: true, store: true })
+  }
+
+  private selectByLabel(label: string): void {
+    const index = this.tabs.findIndex((tab) => tab.textContent?.trim() === label)
+    if (index !== -1) this.select(index)
+  }
+
+  private applyStoredSync(): void {
+    const key = this.syncKey
+    if (!key) return
+    try {
+      const stored = window.localStorage.getItem(STORAGE_PREFIX + key)
+      if (stored) this.selectByLabel(stored)
+    } catch {
+      // Storage can be unavailable (sandboxed iframes, privacy modes) — selection still works.
+    }
+  }
+
+  private select(index: number, options: { focus?: boolean; store?: boolean } = {}): void {
+    const tabs = this.tabs
+    const selected = tabs[index]
+    if (!selected) return
+
+    for (const tab of tabs) {
+      const active = tab === selected
+      tab.setAttribute('aria-selected', active ? 'true' : 'false')
+      if (active) tab.removeAttribute('tabindex')
+      else tab.setAttribute('tabindex', '-1')
+
+      const panel = tab.getAttribute('aria-controls')
+      const panelEl = panel ? this.querySelector(`[role="tabpanel"][id="${panel}"]`) : null
+      if (panelEl) panelEl.toggleAttribute('hidden', !active)
+    }
+
+    if (options.focus) selected.focus()
+
+    const key = this.syncKey
+    if (options.store && key) {
+      const label = selected.textContent?.trim() ?? ''
+      try {
+        window.localStorage.setItem(STORAGE_PREFIX + key, label)
+      } catch {
+        // Ignore storage failures — sync still works for the current page.
+      }
+      window.dispatchEvent(new CustomEvent(SYNC_EVENT, { detail: { key, label } }))
+    }
+  }
+}
diff --git a/packages/comark-prose/src/index.ts b/packages/comark-prose/src/index.ts
new file mode 100644
index 00000000..34b3e410
--- /dev/null
+++ b/packages/comark-prose/src/index.ts
@@ -0,0 +1,135 @@
+import type { ElementNode, Node } from 'comark'
+import { defineComarkPlugin } from 'comark'
+import type { ProseComponentsOptions, ProseContext, ProseOptions } from './types.ts'
+import { attrsOf, concatClass, isElement, setClass } from './utils.ts'
+import { lowerCallout } from './lower/callout.ts'
+import { lowerTabs } from './lower/tabs.ts'
+import { lowerCodeGroup } from './lower/code-group.ts'
+import { lowerAccordion } from './lower/accordion.ts'
+import { lowerSteps } from './lower/steps.ts'
+import { lowerPre } from './lower/copy.ts'
+import { lowerHeading } from './lower/headings.ts'
+import { lowerTable } from './lower/table.ts'
+
+export type * from './types.ts'
+
+const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])
+const CALLOUT_TAGS = new Set(['callout', 'note', 'tip', 'important', 'warning', 'caution', 'blockquote'])
+
+function resolveCopy(components: false | ProseComponentsOptions): ProseContext['copy'] {
+  if (components === false || components.copy === false) return false
+  if (components.copy === undefined || components.copy === true) return {}
+  return components.copy
+}
+
+/** True when `node` is already the lowered wrapper produced by a previous pass. */
+function isWrapper(parent: ElementNode | undefined, tag: string, className: string): boolean {
+  if (!parent || parent[0] !== tag) return false
+  const cls = parent[1]?.class
+  return typeof cls === 'string' && cls.split(' ').includes(className)
+}
+
+function transformNode(
+  node: ElementNode,
+  parent: ElementNode | undefined,
+  ctx: ProseContext
+): Node | false | undefined {
+  const tag = node[0]
+
+  const userTransform = ctx.transform?.[tag]
+  if (userTransform) {
+    const result = userTransform(node)
+    if (result !== undefined) return result
+  }
+
+  const themeClass = ctx.classes?.[tag]
+  if (themeClass) {
+    const resolved = typeof themeClass === 'function' ? themeClass(node) : themeClass
+    if (resolved) setClass(ctx, attrsOf(node), resolved)
+  }
+
+  const components = ctx.components
+  if (components !== false) {
+    if (CALLOUT_TAGS.has(tag) && components.callout !== false) {
+      const lowered = lowerCallout(node, ctx)
+      if (lowered) return lowered
+    }
+    if (tag === 'tabs' && components.tabs !== false) return lowerTabs(node, ctx)
+    if (tag === 'code-group' && components.codeGroup !== false) return lowerCodeGroup(node, ctx)
+    if (tag === 'steps' && components.steps !== false) return lowerSteps(node, ctx)
+    if (tag === 'accordion' && components.accordion !== false) return lowerAccordion(node, ctx)
+    // Guard against re-lowering on streaming re-parses, where already-lowered nodes are reused.
+    if (tag === 'pre' && !isWrapper(parent, 'figure', 'prose-pre')) return lowerPre(node, ctx)
+  }
+
+  if (ctx.elements !== false) {
+    if (tag === 'table') return lowerTable(node, parent, ctx)
+    if (HEADING_TAGS.has(tag)) lowerHeading(node, ctx)
+  }
+
+  return undefined
+}
+
+function walk(node: Node, parent: ElementNode | undefined, ctx: ProseContext): Node | false {
+  if (!isElement(node)) return node
+
+  // Never descend into code blocks: highlighters own that subtree.
+  // Forward order keeps generated ids in document order.
+  if (node[0] !== 'pre') {
+    for (let i = 2; i < node.length; i++) {
+      const result = walk(node[i] as Node, node, ctx)
+      if (result === false) node.splice(i--, 1)
+      else node[i] = result
+    }
+  }
+
+  return transformNode(node, parent, ctx) ?? node
+}
+
+/**
+ * `prose` — framework-agnostic prose for Comark.
+ *
+ * Lowers component tags (`::callout`, `::tabs`, `::code-group`, `::steps`, `::accordion`,
+ * GFM alerts) and structural elements (heading anchors, table scroll wrappers, code-block
+ * copy buttons) into plain HTML at parse time. Every renderer benefits: Vue, React,
+ * Svelte, Angular, and `@comark/html` string output.
+ *
+ * Styling is decoupled: use `@comark/prose/components.css` (token-driven, `:where()`
+ * scoped), any prose stylesheet (Tailwind Typography, shadcn Typeset), or pass a
+ * `classes` map for utility-class design systems. Interactivity is decoupled too: the
+ * optional `@comark/prose/client` runtime (~2 kB) upgrades `` and
+ * ``; without it, tabs render stacked and copy buttons stay hidden.
+ *
+ * @example
+ * ```ts
+ * import { parseMarkdown } from 'comark'
+ * import prose from '@comark/prose'
+ *
+ * const tree = await parseMarkdown(markdown, { plugins: [prose()] })
+ * ```
+ */
+export default defineComarkPlugin((options: ProseOptions = {}) => {
+  return {
+    name: 'prose',
+    post(state) {
+      // Per-document id counters keep SSR markup deterministic.
+      const counters: Record = {}
+      const ctx: ProseContext = {
+        elements: options.elements ?? {},
+        components: options.components ?? {},
+        classes: options.classes,
+        mergeClass: options.mergeClass ?? concatClass,
+        transform: options.transform,
+        copy: resolveCopy(options.components ?? {}),
+        nextId: (kind) => `prose-${kind}-${(counters[kind] = (counters[kind] ?? 0) + 1)}`,
+      }
+
+      const nodes = state.tree.nodes
+      for (let i = 0; i < nodes.length; i++) {
+        const result = walk(nodes[i]!, undefined, ctx)
+        if (result === false) nodes.splice(i--, 1)
+        else nodes[i] = result
+      }
+    },
+  }
+})
diff --git a/packages/comark-prose/src/lower/accordion.ts b/packages/comark-prose/src/lower/accordion.ts
new file mode 100644
index 00000000..22f2e235
--- /dev/null
+++ b/packages/comark-prose/src/lower/accordion.ts
@@ -0,0 +1,51 @@
+import type { ElementNode, Node } from 'comark'
+import type { ProseContext } from '../types.ts'
+import { attrsOf, childrenOf, isElement, setClass, takeAttr, takeBoolAttr } from '../utils.ts'
+
+/**
+ * Lowers a single `::accordion-item{label}` into a native `
`. + * `name` groups items exclusively (one open at a time) — pure HTML, zero JS. + */ +function lowerItem(item: ElementNode, ctx: ProseContext, name: string | undefined): Node { + const attrs = attrsOf(item) + const label = takeAttr(attrs, 'label') ?? 'Details' + const open = takeBoolAttr(attrs, 'open') + + const detailsAttrs: Record = { ...attrs } + if (name) detailsAttrs.name = name + if (open) detailsAttrs.open = '' + setClass(ctx, detailsAttrs, 'prose-accordion-item') + + return [ + 'details', + detailsAttrs, + ['summary', { class: 'prose-accordion-trigger' }, label], + ['div', { class: 'prose-accordion-content' }, ...childrenOf(item)], + ] +} + +/** + * Lowers `::accordion` with `::accordion-item{label}` children into a group of native + * `
` elements. Exclusive open by default; add `{multiple}` on the accordion + * to allow several items open at once. + */ +export function lowerAccordion(node: ElementNode, ctx: ProseContext): Node | undefined { + const attrs = attrsOf(node) + const multiple = takeBoolAttr(attrs, 'multiple') + const name = multiple ? undefined : ctx.nextId('accordion') + + const items: Node[] = [] + for (const child of childrenOf(node)) { + if (isElement(child) && child[0] === 'accordion-item') { + items.push(lowerItem(child, ctx, name)) + } else { + items.push(child) + } + } + + if (items.length === 0) return undefined + + const rootAttrs: Record = { ...attrs } + setClass(ctx, rootAttrs, 'prose-accordion') + return ['div', rootAttrs, ...items] +} diff --git a/packages/comark-prose/src/lower/callout.ts b/packages/comark-prose/src/lower/callout.ts new file mode 100644 index 00000000..b87e9dad --- /dev/null +++ b/packages/comark-prose/src/lower/callout.ts @@ -0,0 +1,52 @@ +import type { ElementNode, Node } from 'comark' +import type { ProseContext } from '../types.ts' +import { attrsOf, childrenOf, setClass, takeAttr } from '../utils.ts' + +const VARIANT_TAGS = new Set(['note', 'tip', 'important', 'warning', 'caution']) + +/** + * Lowers `::callout{color icon}`, the `::note`/`::tip`/`::important`/`::warning`/`::caution` + * shorthands and GFM alerts (`['blockquote', { as }]`, normalized by the default `alert` + * plugin) into one shape: + * + * ```html + *
+ * ``` + * + * `role="note"` is the ARIA role for parenthetic content; `