Skip to content

feat(prose): add @comark/prose — framework-agnostic prose components (WIP) - #386

Open
atinux wants to merge 7 commits into
mainfrom
feat/comark-prose
Open

feat(prose): add @comark/prose — framework-agnostic prose components (WIP)#386
atinux wants to merge 7 commits into
mainfrom
feat/comark-prose

Conversation

@atinux

@atinux atinux commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What

New package @comark/prose: makes docs components framework-agnostic by lowering them into plain HTML at parse time, so every renderer benefits — Vue, React, Svelte, Angular, and @comark/html string output. Discussion context: making comark prose work with any styling system (Nuxt UI utility classes, Tailwind Typography, shadcn Typeset) with minimal client JS.

Three independent layers, each optional:

1. Plugin (@comark/prose)

Markdown Output JS
::note family, ::callout{color}, > [!NOTE] <div class="prose-callout" role="note" data-variant> (CSS mask icons; role=note avoids landmark noise from <aside>) none
::tabs + ::tab-item{label} <prose-tabs> WAI-ARIA tablist, deterministic ids switching
::code-group same tabs markup, labelled by [filename]/language switching
::steps{level} CSS counters on child headings none
::accordion + ::accordion-item{label} native <details name> (exclusive; {multiple} opt-out) none
code fences <figure class="prose-pre"> + filename header + <prose-copy> copy
headings h2–h4 content wrapped in <a href="#id"> + hash icon (skips headings containing links — no nested anchors) none
tables horizontal scroll wrapper none

Also: classes/mergeClass map to bake utility-class design systems into plain tags, and a per-tag transform escape hatch. Streaming-safe: re-parses never double-wrap, ids stay deterministic.

2. CSS (components.css, typography.css, styles/*.css)

  • Token-driven: --prose-size / --prose-leading / --prose-flow + light-dark() color hooks
  • Zero-specificity :where() scoped to .comark-content; plain utilities override without !important
  • Append-stable for streaming (only margin-block-start, no :last-child/:has())
  • Built + minified with lightningcss (first CSS-shipping package in the monorepo; partials exported for cherry-picking)
  • typography.css is optional — Tailwind Typography / shadcn Typeset work as-is on the lowered markup

3. Client runtime (client, client/register)

Two dependency-free custom elements, SSR-safe, ~2 kB total:

  • <prose-tabs>: WAI-APG keyboard nav (arrows/Home/End, automatic activation), data-sync group sync persisted in localStorage, MutationObserver for streamed panels
  • <prose-copy>: clipboard copy with data-copied icon swap and a live-region announcement

No-JS story: tab panels render stacked via prose-tabs:not(:defined) (content always reachable), copy buttons stay hidden, everything else is pure HTML/CSS.

Also included

  • fix(comark): keep literal aria-* attribute values in HTML outputhtmlAttributes collapsed 'true' to bare attributes; for ARIA that inverts the meaning (aria-hidden="" is not hidden). Found while building this; separate commit.
  • Docs page (/plugins/prose), framework-free example (examples/3.plugins/html-prose: renderHtml + one script tag = fully interactive docs page), AGENTS.md section, bundle snapshot (@comark/prose: 74.2k published).

Tests

27 new tests: 21 lowering (exact node assertions, streaming re-parse guards, options) + 6 client (happy-dom: click/keyboard/sync/copy). All package suites pass; pnpm lint + pnpm typecheck clean.

Known pre-existing flake (unrelated): packages/comark/test/index.test.ts SPEC fixtures with 5–10 ms perf-budget hook timeouts occasionally time out under full parallel runs, also on main.

Follow-ups (out of scope)

  • template-slot (#slot) form of tabs
  • mermaid / image-zoom lowering
  • first-class prose: false renderer option so Nuxt UI apps don't need a components-map override
  • comark-docs adoption (Nuxt UI preset via classes/mergeClass)

🤖 Opened by an AI coding agent (OpenCode) on behalf of @atinux, who reviewed the changes.

atinux added 3 commits August 26, 2026 12:58
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.
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.
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) ->
  <div role=note data-variant> with CSS mask icons
- ::tabs / ::code-group -> <prose-tabs> WAI-ARIA tablist; panels render
  stacked without JavaScript
- ::accordion -> native <details name> groups (exclusive by default)
- ::steps -> CSS counters on child headings
- code fences -> figure with filename header + <prose-copy> 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 (<prose-tabs>
  with keyboard nav + localStorage group sync, <prose-copy>), 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.
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
comark Ready Ready Preview Aug 26, 2026 2:15pm
comark-json-render Ready Ready Preview Aug 26, 2026 2:15pm
comark-nextjs Ready Ready Preview Aug 26, 2026 2:15pm
comark-nuxt Ready Ready Preview Aug 26, 2026 2:15pm
comark-svelte Ready Ready Preview Aug 26, 2026 2:15pm
comark-sveltekit Ready Ready Preview Aug 26, 2026 2:15pm
comark-twoslash Ready Ready Preview Aug 26, 2026 2:15pm
comark-vue Ready Ready Preview Aug 26, 2026 2:15pm

atinux added 2 commits August 26, 2026 16:03
Deriving the hoisted names from the registered defaultPlugins array
instead of a hardcoded set removes the duplicated list and makes the
hoisting a natural no-op with registerDefaultPlugins: false, where the
user's explicit registration order rules. Adds a test for that case.
atinux added 2 commits August 26, 2026 16:11
Same rationale as the post-hook ordering: among the defaults only
frontmatter has a pre hook, and running it first gives user pre hooks a
normalized contract — state.markdown without the frontmatter block,
state.frontmatter and state.frontmatterText already populated — instead
of raw text a rewriting plugin could corrupt. One partitioned list now
drives both lifecycle phases; markdownItPlugins keep registration order.
Base automatically changed from fix/default-post-hook-order to main August 26, 2026 15:11
@atinux atinux changed the title feat(prose): add @comark/prose — framework-agnostic prose components feat(prose): add @comark/prose — framework-agnostic prose components (WIP) Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant