Skip to content
Draft
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
41 changes: 41 additions & 0 deletions .github/workflows/codspeed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: codspeed

on:
push:
branches:
- main
pull_request:
branches:
- main
# Manual runs are useful when iterating on fixtures before merge.
workflow_dispatch:

permissions: {}

jobs:
benchmarks:
name: CodSpeed benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
package-manager-cache: false
node-version: '24'

- run: npm i -g --force corepack && corepack enable
- run: pnpm install

# Bench imports use package names (`comark`) and TS sources under packages.
# A real build ensures workspace package entry points resolve correctly.
- run: pnpm build:comark && pnpm --filter @comark/html run build

- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@v4
with:
# Optional until the CodSpeed GitHub app is installed + secret is set.
# Without it the action still runs local CPU simulation and reports status.
token: ${{ secrets.CODSPEED_TOKEN }}
mode: simulation
run: pnpm bench
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ This is a **monorepo** containing the Comark Markdown parser, document model, pl
│ ├── 2.vite/ # Vite examples (Vue, React, Svelte, Angular, HTML, ANSI)
│ └── 3.plugins/ # Plugin examples (math, mermaid, highlight, ...)
├── docs/ # Documentation site (comark-docs layer)
├── benchmarks/ # CodSpeed / Vitest benches (isolated from unit tests)
├── scripts/ # Build/sync scripts
├── pnpm-workspace.yaml # Workspace configuration
├── tsconfig.json # Root TypeScript config
Expand Down Expand Up @@ -686,6 +687,7 @@ Root workspace scripts:
pnpm docs # Run documentation site
pnpm build # Build all packages
pnpm test # Run all package tests
pnpm bench # Run CodSpeed-compatible Vitest benches (local wall-clock fallback)
pnpm lint # Run ESLint
pnpm typecheck # Run TypeScript check
pnpm verify # Run lint + test + typecheck
Expand All @@ -705,6 +707,7 @@ Workflows live in `.github/workflows/`:
| Workflow | Purpose |
|----------|---------|
| `ci.yml` | lint → prepack → test → publish preview → bundle size check |
| `codspeed.yml` | Isolated CodSpeed CPU-simulation benches on main + PRs |
| `commit-signature.yml` | Fails PRs containing unsigned commits |
| `bundle-snapshot.yml` | Reports bundle-size snapshot drift and updates it on demand |

Expand Down
71 changes: 71 additions & 0 deletions benchmarks/comark-parse.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { bench, describe } from 'vitest'
import { createMarkdownParser } from 'comark'
import {
adversarialMarkdown,
componentHeavyMarkdown,
incompleteMarkdown,
largeMarkdown,
mediumMarkdown,
smallMarkdown,
} from './fixtures.ts'

const parse = createMarkdownParser()
const parseNoClose = createMarkdownParser({ autoClose: false })
const parseStreaming = createMarkdownParser()

// Warm once so CodSpeed samples steady-state parse, not cold plugin init.
await parse(mediumMarkdown)
await parseNoClose(mediumMarkdown)
await parseStreaming(mediumMarkdown, { streaming: true })

describe('comark parse', () => {
bench('small', async () => {
await parse(smallMarkdown)
})

bench('medium', async () => {
await parse(mediumMarkdown)
})

bench('large', async () => {
await parse(largeMarkdown)
})

bench('component-heavy', async () => {
await parse(componentHeavyMarkdown)
})

bench('adversarial', async () => {
await parse(adversarialMarkdown)
})
})

describe('comark parse (autoClose: false)', () => {
bench('medium', async () => {
await parseNoClose(mediumMarkdown)
})

bench('incomplete', async () => {
await parseNoClose(incompleteMarkdown)
})
})

describe('comark parse (streaming)', () => {
// Reset stream cache then feed growing prefixes so we measure real
// incremental work, not the no-op reuse of an identical full string.
async function streamingIncremental(markdown: string): Promise<void> {
await parseStreaming('\n', { streaming: false })
const steps = [0.25, 0.5, 0.75, 1].map((f) => markdown.slice(0, Math.floor(markdown.length * f)))
for (const step of steps) {
await parseStreaming(step, { streaming: true })
}
}

bench('medium incremental', async () => {
await streamingIncremental(mediumMarkdown)
})

bench('incomplete incremental', async () => {
await streamingIncremental(incompleteMarkdown)
})
})
82 changes: 82 additions & 0 deletions benchmarks/comark-render.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { bench, describe } from 'vitest'
import { createMarkdownParser } from 'comark'
import { renderMarkdown } from 'comark/render'
import { renderHtmlFromDocument } from '../packages/comark-html/src/index.ts'
import { componentHeavyMarkdown, largeMarkdown, mediumMarkdown, smallMarkdown } from './fixtures.ts'

const parse = createMarkdownParser()

// Pre-parse so render-only benches measure stringify, not parse.
const smallDoc = await parse(smallMarkdown)
const mediumDoc = await parse(mediumMarkdown)
const largeDoc = await parse(largeMarkdown)
const componentDoc = await parse(componentHeavyMarkdown)

// Warm both render paths once.
await renderHtmlFromDocument(mediumDoc)
await renderMarkdown(mediumDoc)

describe('comark renderHtmlFromDocument', () => {
bench('small', async () => {
await renderHtmlFromDocument(smallDoc)
})

bench('medium', async () => {
await renderHtmlFromDocument(mediumDoc)
})

bench('large', async () => {
await renderHtmlFromDocument(largeDoc)
})

bench('component-heavy', async () => {
await renderHtmlFromDocument(componentDoc)
})
})

describe('comark renderMarkdown', () => {
bench('small', async () => {
await renderMarkdown(smallDoc)
})

bench('medium', async () => {
await renderMarkdown(mediumDoc)
})

bench('large', async () => {
await renderMarkdown(largeDoc)
})

bench('component-heavy', async () => {
await renderMarkdown(componentDoc)
})
})

describe('comark parse + renderHtmlFromDocument', () => {
bench('medium', async () => {
const doc = await parse(mediumMarkdown)
await renderHtmlFromDocument(doc)
})

bench('large', async () => {
const doc = await parse(largeMarkdown)
await renderHtmlFromDocument(doc)
})
})

describe('comark parse + renderMarkdown', () => {
bench('medium', async () => {
const doc = await parse(mediumMarkdown)
await renderMarkdown(doc)
})

bench('large', async () => {
const doc = await parse(largeMarkdown)
await renderMarkdown(doc)
})

bench('component-heavy', async () => {
const doc = await parse(componentHeavyMarkdown)
await renderMarkdown(doc)
})
})
141 changes: 141 additions & 0 deletions benchmarks/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Shared fixture inputs for CodSpeed / Vitest benches.
* Isolated Comark-only workloads — no markdown-it/exit comparisons.
*/

/** Small CommonMark document (~few hundred chars). */
export const smallMarkdown = `# Hello

This is a **bold** paragraph with *italic* text and a [link](https://example.com).

- item one
- item two
`

/** Medium document: headings, lists, table, code, blockquote, components. */
export const mediumMarkdown = `---
title: Benchmark Test
---

# Hello World

This is a **markdown** document with *italic* text and [links](https://example.com).

## Features

- List item 1
- List item 2
- List item 3

### Code Block

\`\`\`javascript
const hello = 'world'
console.log(hello)
\`\`\`

### Tables

| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |

### MDC Components

::alert{type="info"}
This is an alert component
::

::card{title="My Card"}
Card content here
::

### More Content

1. Numbered list
2. Another item
3. Final item

> This is a blockquote with some **bold** text

~~Strikethrough text~~
`

/** Large synthetic document (~100 sections). */
export const largeMarkdown = Array.from({ length: 100 })
.fill(
`# Heading

This is a paragraph with **bold**, *italic*, and a [link](https://example.com).

- list item a
- list item b

\`\`\`js
const x = 1
\`\`\`
`
)
.join('\n')

/** Nested block / inline components — exercises the components + attributes plugins. */
export const componentHeavyMarkdown = `::parent{id="root"}
# Nested components

::child{variant="a"}
Inline :badge[hot]{color="red"} and :icon[star]{name="star"}.

:::nested
Deep content with **formatting** and a [link](https://example.com).
:::
::

::card{title="Card A"}
Body A with \`code\` and *emphasis*.
::

::card{title="Card B"}
Body B

- one
- two
::
::

Hello :world[Inline Component Content]{data-component="test"} again.
`

/**
* Incomplete / streaming-style markdown (unclosed emphasis, fence, component).
* Used with autoClose on/off and streaming mode.
*/
export const incompleteMarkdown = `# Streaming draft

This has **unclosed bold and a list:

- item 1
- item 2

\`\`\`ts
function incomplete(

::alert{type="warning"
Partial component body
`

/** Pathological nesting and long runs that stress the scanner. */
export const adversarialMarkdown = `${'['.repeat(50)}text${']'.repeat(50)}

${'*'.repeat(40)}borderline emphasis${'*'.repeat(40)}

\`\`\`js
${'// comment\n'.repeat(200)}
\`\`\`

| ${'a | '.repeat(30)}
| ${'--- | '.repeat(30)}
| ${'b | '.repeat(30)}

${'::wrapper\n'.repeat(20)}${'inner\n'.repeat(5)}${'::\n'.repeat(20)}
`
17 changes: 17 additions & 0 deletions benchmarks/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import codspeedPlugin from '@codspeed/vitest-plugin'
import { defineConfig } from 'vitest/config'

/**
* Isolated CodSpeed / Vitest bench config.
* Keep this separate from package unit-test configs so CI can run benches
* without dragging in the rest of the monorepo test graph.
*/
export default defineConfig({
plugins: [codspeedPlugin()],
test: {
environment: 'node',
globals: false,
// bench files only — never pull in unit tests
include: ['**/*.bench.ts'],
},
})
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@
"lint": "oxlint . && oxfmt --check .",
"lint:fix": "oxlint --fix . && oxfmt .",
"typecheck": "tsc --noEmit",
"bench": "vitest bench --run --config benchmarks/vitest.config.ts",
"verify": "pnpm run lint && pnpm run test && pnpm run typecheck",
"release": "node scripts/release.mjs",
"release:dry": "node scripts/release.mjs --dry",
"postinstall": "pnpm stub"
},
"devDependencies": {
"@codspeed/vitest-plugin": "^5.7.1",
"@comark/ansi": "workspace:*",
"@release-it/conventional-changelog": "catalog:",
"@types/node": "catalog:",
Expand Down
Loading
Loading