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
21 changes: 21 additions & 0 deletions .changeset/gfm-checkbox-accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"markdown-to-jsx": minor
---

GFM task list checkboxes now include proper `<label>` elements with matching `id` and `for` attributes for improved accessibility. This allows screen readers to properly announce what each checkbox represents.

**Before:**
```html
<li><input type="checkbox"/> Task text</li>
```

**After:**
```html
<li><input id="task-1" type="checkbox"/><label for="task-1"> Task text</label></li>
```

React adapter uses `React.useId` (React 18+) with fallback counter for older versions. HTML, SolidJS, and Vue adapters use a global counter for ID generation.

GFM task 列表复选框现在包含带有匹配的 `id` 和 `for` 属性的正确 `<label>` 元素,以提高可访问性。这允许屏幕阅读器正确地宣布每个复选框代表什么。

GFM टास्क लिस्ट चेकबॉक्स अब एक्सेसिबिलिटी में सुधार के लिए मिलान `id` और `for` एट्रिब्यूट्स के साथ उचित `<label>` एलिमेंट्स शामिल करते हैं। यह स्क्रीन रीडर्स को प्रत्येक चेकबॉक्स का प्रतिनिधित्व करने वाली चीज़ को ठीक से घोषित करने की अनुमति देता है।
70 changes: 35 additions & 35 deletions src/commonmark-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"section": "Tabs"
},
{
"markdown": " a\ta\n \ta\n",
"html": "<pre><code>a\ta\nὐ\ta\n</code></pre>\n",
"markdown": " a\ta\n \u1f50\ta\n",
"html": "<pre><code>a\ta\n\u1f50\ta\n</code></pre>\n",
"example": 3,
"start_line": 369,
"end_line": 376,
Expand Down Expand Up @@ -96,8 +96,8 @@
"section": "Backslash escapes"
},
{
"markdown": "\\\t\\A\\a\\ \\3\\φ\\«\n",
"html": "<p>\\\t\\A\\a\\ \\3\\φ\\«</p>\n",
"markdown": "\\\t\\A\\a\\ \\3\\\u03c6\\\u00ab\n",
"html": "<p>\\\t\\A\\a\\ \\3\\\u03c6\\\u00ab</p>\n",
"example": 13,
"start_line": 499,
"end_line": 503,
Expand Down Expand Up @@ -193,23 +193,23 @@
},
{
"markdown": "&nbsp; &amp; &copy; &AElig; &Dcaron;\n&frac34; &HilbertSpace; &DifferentialD;\n&ClockwiseContourIntegral; &ngE;\n",
"html": "<p>  &amp; © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸</p>\n",
"html": "<p>\u00a0 &amp; \u00a9 \u00c6 \u010e\n\u00be \u210b \u2146\n\u2232 \u2267\u0338</p>\n",
"example": 25,
"start_line": 649,
"end_line": 657,
"section": "Entity and numeric character references"
},
{
"markdown": "&#35; &#1234; &#992; &#0;\n",
"html": "<p># Ӓ Ϡ �</p>\n",
"html": "<p># \u04d2 \u03e0 \ufffd</p>\n",
"example": 26,
"start_line": 668,
"end_line": 672,
"section": "Entity and numeric character references"
},
{
"markdown": "&#X22; &#XD06; &#xcab;\n",
"html": "<p>&quot; ആ ಫ</p>\n",
"html": "<p>&quot; \u0d06 \u0cab</p>\n",
"example": 27,
"start_line": 681,
"end_line": 685,
Expand Down Expand Up @@ -249,23 +249,23 @@
},
{
"markdown": "[foo](/f&ouml;&ouml; \"f&ouml;&ouml;\")\n",
"html": "<p><a href=\"/f%C3%B6%C3%B6\" title=\"föö\">foo</a></p>\n",
"html": "<p><a href=\"/f%C3%B6%C3%B6\" title=\"f\u00f6\u00f6\">foo</a></p>\n",
"example": 32,
"start_line": 735,
"end_line": 739,
"section": "Entity and numeric character references"
},
{
"markdown": "[foo]\n\n[foo]: /f&ouml;&ouml; \"f&ouml;&ouml;\"\n",
"html": "<p><a href=\"/f%C3%B6%C3%B6\" title=\"föö\">foo</a></p>\n",
"html": "<p><a href=\"/f%C3%B6%C3%B6\" title=\"f\u00f6\u00f6\">foo</a></p>\n",
"example": 33,
"start_line": 742,
"end_line": 748,
"section": "Entity and numeric character references"
},
{
"markdown": "``` f&ouml;&ouml;\nfoo\n```\n",
"html": "<pre><code class=\"language-föö\">foo\n</code></pre>\n",
"html": "<pre><code class=\"language-f\u00f6\u00f6\">foo\n</code></pre>\n",
"example": 34,
"start_line": 751,
"end_line": 758,
Expand Down Expand Up @@ -1640,8 +1640,8 @@
"section": "Link reference definitions"
},
{
"markdown": "[ΑΓΩ]: /φου\n\n[αγω]\n",
"html": "<p><a href=\"/%CF%86%CE%BF%CF%85\">αγω</a></p>\n",
"markdown": "[\u0391\u0393\u03a9]: /\u03c6\u03bf\u03c5\n\n[\u03b1\u03b3\u03c9]\n",
"html": "<p><a href=\"/%CF%86%CE%BF%CF%85\">\u03b1\u03b3\u03c9</a></p>\n",
"example": 206,
"start_line": 3348,
"end_line": 3354,
Expand Down Expand Up @@ -2656,16 +2656,16 @@
"section": "Code spans"
},
{
"markdown": "` b `\n",
"html": "<p><code> b </code></p>\n",
"markdown": "`\u00a0b\u00a0`\n",
"html": "<p><code>\u00a0b\u00a0</code></p>\n",
"example": 333,
"start_line": 5933,
"end_line": 5937,
"section": "Code spans"
},
{
"markdown": "` `\n` `\n",
"html": "<p><code> </code>\n<code> </code></p>\n",
"markdown": "`\u00a0`\n` `\n",
"html": "<p><code>\u00a0</code>\n<code> </code></p>\n",
"example": 334,
"start_line": 5941,
"end_line": 5947,
Expand Down Expand Up @@ -2816,16 +2816,16 @@
"section": "Emphasis and strong emphasis"
},
{
"markdown": "* a *\n",
"html": "<p>* a *</p>\n",
"markdown": "*\u00a0a\u00a0*\n",
"html": "<p>*\u00a0a\u00a0*</p>\n",
"example": 353,
"start_line": 6338,
"end_line": 6342,
"section": "Emphasis and strong emphasis"
},
{
"markdown": "*$*alpha.\n\n*£*bravo.\n\n**charlie.\n",
"html": "<p>*$*alpha.</p>\n<p>*£*bravo.</p>\n<p>**charlie.</p>\n",
"markdown": "*$*alpha.\n\n*\u00a3*bravo.\n\n*\u20ac*charlie.\n",
"html": "<p>*$*alpha.</p>\n<p>*\u00a3*bravo.</p>\n<p>*\u20ac*charlie.</p>\n",
"example": 354,
"start_line": 6347,
"end_line": 6357,
Expand Down Expand Up @@ -2888,8 +2888,8 @@
"section": "Emphasis and strong emphasis"
},
{
"markdown": "пристаням_стремятся_\n",
"html": "<p>пристаням_стремятся_</p>\n",
"markdown": "\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c_\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f_\n",
"html": "<p>\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c_\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f_</p>\n",
"example": 362,
"start_line": 6421,
"end_line": 6425,
Expand Down Expand Up @@ -2992,8 +2992,8 @@
"section": "Emphasis and strong emphasis"
},
{
"markdown": "_пристаням_стремятся\n",
"html": "<p>_пристаням_стремятся</p>\n",
"markdown": "_\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c_\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f\n",
"html": "<p>_\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c_\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f</p>\n",
"example": 375,
"start_line": 6553,
"end_line": 6557,
Expand Down Expand Up @@ -3096,8 +3096,8 @@
"section": "Emphasis and strong emphasis"
},
{
"markdown": "пристаням__стремятся__\n",
"html": "<p>пристаням__стремятся__</p>\n",
"markdown": "\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c__\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f__\n",
"html": "<p>\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c__\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f__</p>\n",
"example": 388,
"start_line": 6672,
"end_line": 6676,
Expand Down Expand Up @@ -3200,8 +3200,8 @@
"section": "Emphasis and strong emphasis"
},
{
"markdown": "__пристаням__стремятся\n",
"html": "<p>__пристаням__стремятся</p>\n",
"markdown": "__\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c__\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f\n",
"html": "<p>__\u043f\u0440\u0438\u0441\u0442\u0430\u043d\u044f\u043c__\u0441\u0442\u0440\u0435\u043c\u044f\u0442\u0441\u044f</p>\n",
"example": 401,
"start_line": 6799,
"end_line": 6803,
Expand Down Expand Up @@ -4048,7 +4048,7 @@
"section": "Links"
},
{
"markdown": "[link](/url \"title\")\n",
"markdown": "[link](/url\u00a0\"title\")\n",
"html": "<p><a href=\"/url%C2%A0%22title%22\">link</a></p>\n",
"example": 507,
"start_line": 7776,
Expand Down Expand Up @@ -4312,8 +4312,8 @@
"section": "Links"
},
{
"markdown": "[]\n\n[SS]: /url\n",
"html": "<p><a href=\"/url\"></a></p>\n",
"markdown": "[\u1e9e]\n\n[SS]: /url\n",
"html": "<p><a href=\"/url\">\u1e9e</a></p>\n",
"example": 540,
"start_line": 8129,
"end_line": 8135,
Expand Down Expand Up @@ -5200,8 +5200,8 @@
"section": "Textual content"
},
{
"markdown": "Foo χρῆν\n",
"html": "<p>Foo χρῆν</p>\n",
"markdown": "Foo \u03c7\u03c1\u1fc6\u03bd\n",
"html": "<p>Foo \u03c7\u03c1\u1fc6\u03bd</p>\n",
"example": 651,
"start_line": 9402,
"end_line": 9406,
Expand Down Expand Up @@ -5281,15 +5281,15 @@
},
{
"markdown": "- [ ] foo\n- [x] bar\n",
"html": "<ul>\n<li><input disabled=\"\" type=\"checkbox\"> foo</li>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> bar</li>\n</ul>\n",
"html": "<ul>\n<li><input disabled=\"\" id=\"task-1\" type=\"checkbox\"><label for=\"task-1\"> foo</label></li>\n<li><input checked=\"\" disabled=\"\" id=\"task-2\" type=\"checkbox\"><label for=\"task-2\"> bar</label></li>\n</ul>\n",
"example": 661,
"start_line": 5108,
"end_line": 5116,
"section": "Task list items"
},
{
"markdown": "- [x] foo\n - [ ] bar\n - [x] baz\n- [ ] bim\n",
"html": "<ul>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> foo\n<ul>\n<li><input disabled=\"\" type=\"checkbox\"> bar</li>\n<li><input checked=\"\" disabled=\"\" type=\"checkbox\"> baz</li>\n</ul>\n</li>\n<li><input disabled=\"\" type=\"checkbox\"> bim</li>\n</ul>\n",
"html": "<ul>\n<li><input checked=\"\" disabled=\"\" id=\"task-1\" type=\"checkbox\"><label for=\"task-1\"> foo\n<ul>\n<li><input disabled=\"\" id=\"task-2\" type=\"checkbox\"><label for=\"task-2\"> bar</label></li>\n<li><input checked=\"\" disabled=\"\" id=\"task-3\" type=\"checkbox\"><label for=\"task-3\"> baz</label></li>\n</ul>\n</label></li>\n<li><input disabled=\"\" id=\"task-4\" type=\"checkbox\"><label for=\"task-4\"> bim</label></li>\n</ul>\n",
"example": 662,
"start_line": 5120,
"end_line": 5135,
Expand Down
5 changes: 4 additions & 1 deletion src/commonmark.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test'
import * as fs from 'fs'
import * as path from 'path'

import { astToHTML } from './html'
import { astToHTML, _resetGfmTaskIdCounter } from './html'
import { parser } from './parse'

type SpecExample = {
Expand Down Expand Up @@ -167,6 +167,9 @@ describe('CommonMark 0.31.2 Specification', () => {
it.each(specExamples)(
'Example $example: $section (lines $start_line-$end_line)',
({ markdown, html: expectedHtml, section }) => {
// Reset GFM task ID counter for consistent test output
_resetGfmTaskIdCounter()

// Enable GFM extensions for autolinks and tagfilter tests
// Note: angle bracket autolinks (<https://...>) are part of core CommonMark spec
// GFM bare URL autolinks (www., http:// without <>) are extensions
Expand Down
44 changes: 43 additions & 1 deletion src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ export { parser } from './parse'
export { RuleType, type MarkdownToJSX } from './types'
export { sanitizer, slugify } from './utils'

// Counter for generating unique GFM task IDs in HTML output
// Note: This counter is global to ensure unique IDs within a single JS context.
// For typical usage (rendering one document at a time), this works well.
// For advanced use cases requiring isolation, consider resetting via _resetGfmTaskIdCounter().
let gfmTaskIdCounter = 0

/**
* Reset the GFM task ID counter (for testing purposes only)
* @internal
*/
export function _resetGfmTaskIdCounter(): void {
gfmTaskIdCounter = 0
}

/**
* Escape HTML entities for text content
* Fast path: return early if no escaping needed
Expand Down Expand Up @@ -726,7 +740,35 @@ export function astToHTML(
var items = ''
var listItems = node.items || []
for (var li = 0; li < listItems.length; li++) {
items += '<li>' + astToHTML(listItems[li], updatedOptions) + '</li>'
var itemContent = listItems[li]
// Check if the first item is a GFM task
if (
itemContent[0] &&
itemContent[0].type === RuleType.gfmTask
) {
var taskNode = itemContent[0] as MarkdownToJSX.GFMTaskNode
var taskId = 'task-' + ++gfmTaskIdCounter
// Per GFM spec, HTML checkboxes use disabled="" attribute
// React/Vue/Solid adapters use readOnly for better form handling
var checkboxHtml =
'<input' +
(taskNode.completed ? ' checked=""' : '') +
' disabled="" id="' +
taskId +
'" type="checkbox">'
// Render remaining items (skip the task node itself)
var labelContent = astToHTML(itemContent.slice(1), updatedOptions)
items +=
'<li>' +
checkboxHtml +
'<label for="' +
taskId +
'">' +
labelContent +
'</label></li>'
} else {
items += '<li>' + astToHTML(itemContent, updatedOptions) + '</li>'
}
}
const tag = node.type === RuleType.orderedList ? 'ol' : 'ul'
const attrs =
Expand Down
34 changes: 24 additions & 10 deletions src/react.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1158,24 +1158,40 @@ describe('GFM task lists', () => {
render(compiler('- [ ] foo'))

expect(root.innerHTML).toMatchInlineSnapshot(
`"<ul><li><input readOnly="" type="checkbox"/> foo</li></ul>"`
)
`"<ul><li><input id="_R_1_" readOnly="" type="checkbox"/><label for="_R_1_"> foo</label></li></ul>"`)
})

it('should handle checked items', () => {
render(compiler('- [x] foo'))

expect(root.innerHTML).toMatchInlineSnapshot(
`"<ul><li><input readOnly="" type="checkbox" checked=""/> foo</li></ul>"`
)
`"<ul><li><input id="_R_1_" readOnly="" type="checkbox" checked=""/><label for="_R_1_"> foo</label></li></ul>"`)
})

it('should mark the checkboxes as readonly', () => {
render(compiler('- [x] foo'))

expect(root.innerHTML).toMatchInlineSnapshot(
`"<ul><li><input readOnly="" type="checkbox" checked=""/> foo</li></ul>"`
)
`"<ul><li><input id="_R_1_" readOnly="" type="checkbox" checked=""/><label for="_R_1_"> foo</label></li></ul>"`)
})

it('should have matching id and for attributes for accessibility', () => {
render(compiler('- [ ] Task item'))

// Extract id from input and for from label
const idMatch = root.innerHTML.match(/id="([^"]+)"/)
const forMatch = root.innerHTML.match(/for="([^"]+)"/)

expect(idMatch).toBeTruthy()
expect(forMatch).toBeTruthy()
expect(idMatch![1]).toBe(forMatch![1])
})

it('should preserve formatted content in labels', () => {
render(compiler('- [ ] **bold** text'))

expect(root.innerHTML).toMatchInlineSnapshot(
`"<ul><li><input id="_R_1_" readOnly="" type="checkbox"/><label for="_R_1_"> <strong>bold</strong> text</label></li></ul>"`)
})
})

Expand Down Expand Up @@ -2963,8 +2979,7 @@ describe('overrides', () => {
)

expect(root.innerHTML).toMatchInlineSnapshot(
`"<ul><li class="foo"><input readOnly="" type="checkbox"/> foo</li></ul>"`
)
`"<ul><li class="foo"><input id="_R_1_" readOnly="" type="checkbox"/><label for="_R_1_"> foo</label></li></ul>"`)
})

it('should be able to override gfm task list item checkboxes', () => {
Expand All @@ -2975,8 +2990,7 @@ describe('overrides', () => {
)

expect(root.innerHTML).toMatchInlineSnapshot(
`"<ul><li><input readOnly="" type="checkbox" class="foo"/> foo</li></ul>"`
)
`"<ul><li><input id="_R_1_" readOnly="" type="checkbox" class="foo"/><label for="_R_1_"> foo</label></li></ul>"`)
})

it('should substitute the appropriate JSX tag if given a component and disableParsingRawHTML is true', () => {
Expand Down
Loading