diff --git a/astro.config.mjs b/astro.config.mjs index 19fbcb5832..3b75936528 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -12,7 +12,7 @@ import rehypeSlug from 'rehype-slug'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import redirects from './src/config/redirects/index.js'; import movedPages from './src/config/redirects/moved-pages.json' with { type: 'json' }; -import { accessibleTablesIntegration } from './src/plugins/rehype-accessible-tables.mjs'; +import rehypeAccessibleTables from './src/plugins/rehype-accessible-tables.mjs'; import remarkRewriteLocalizedLinks from './src/plugins/remark-rewrite-localized-links.mjs'; import remarkCodeTabs from './src/plugins/remark-code-tabs.mjs'; import rehypeRewriteLocalizedLinks from './src/plugins/rehype-rewrite-localized-links.mjs'; @@ -45,6 +45,7 @@ export default defineConfig({ behavior: 'wrap', }, ], + rehypeAccessibleTables, ], }), }, @@ -68,7 +69,6 @@ export default defineConfig({ borderWidth: 'var(--border-width-1)', }, }), - accessibleTablesIntegration(), mdx(), icon(), react(), diff --git a/docs/content.md b/docs/content.md index 78ef752884..156cb8e9ec 100644 --- a/docs/content.md +++ b/docs/content.md @@ -300,3 +300,51 @@ Rules: - **`resources`, `support`, `blog`** are never versioned; links are language-only. - External URLs, relative links (`./x`), pure anchors (`#x`) and already-localized links (`/en/...`) are left untouched. + +## Plugins + +### 1. rehypeAccessibleTables() + +#### Why was this plugin created? + +The documentation team chose **Markdown tables as the preferred and simplest way to write tables in the documentation**. + +Markdown tables are easier to read and maintain in source files, especially for contributors. For example: + +```md +| Method | Description | +| ------ | ------------- | +| GET | Retrieve data | +| POST | Create data | +``` + +Because Markdown tables are the preferred format, we want contributors to be able to write tables using simple Markdown without having to worry about additional HTML markup for accessibility or responsive behavior. + +However, Markdown source does not provide everything we need for the final website. The generated table may need additional attributes and styling behavior, such as: + +```html +Method +``` + +and a wrapper for horizontal scrolling: + +```html +
+ + ... +
+
+``` + +Tables can also be written using raw HTML or MDX JSX in cases where Markdown does not provide enough flexibility. + +Instead of asking contributors to manually add this markup, the `rehype-accessible-tables` plugin applies the required changes automatically during the Astro build process. + +This gives us the best of both approaches: + +- **Markdown remains the preferred authoring format** for documentation tables. +- **Accessibility and responsive behavior are handled automatically** by the build pipeline. +- **Raw HTML and MDX JSX tables are also supported** when more control is needed. +- **The generated website maintains consistent table behavior** across the documentation. + +This keeps the source documentation simple while allowing the generated website to have better accessibility, responsiveness, and consistency. diff --git a/project-words.txt b/project-words.txt index 255568c0cd..1c4bf6a1b8 100644 --- a/project-words.txt +++ b/project-words.txt @@ -89,6 +89,7 @@ Português Prunus Riva SAWARATSUKI +scroller sder secr sendfile diff --git a/src/content/blog/2025-03-31-v5-1-latest-release.md b/src/content/blog/2025-03-31-v5-1-latest-release.md index 3450d0d5ef..ca3824c852 100644 --- a/src/content/blog/2025-03-31-v5-1-latest-release.md +++ b/src/content/blog/2025-03-31-v5-1-latest-release.md @@ -100,16 +100,12 @@ Express major versions will go through three supported phases: For the existing release lines, we will set the following phase dates: -
- | Major | CURRENT | ACTIVE | MAINTENANCE | EOL | | ----- | ------------------------------- | ---------- | ----------------------------- | ----------------------------- | | 4.x | | | 2025-04-01 | \*no sooner than 2026-10-01 | | 5.x | 2024-09-11 | 2025-03-31 | \*\*no sooner than 2026-04-01 | \*\*no sooner than 2027-04-01 | | 6.x | \*\*\*no sooner than 2026-01-01 | | | | -
- As you can see, this means that v5.1.0 being tagged `latest` indicates that we moved from `CURRENT` to `ACTIVE` which starts the clock on EOL for v4 by moving it to `MAINTENANCE`. We recognize that v4 is a special case having been the only major version for most of the history of Node.js itself. Because of this, we want to remain flexible and also diff --git a/src/content/docs/en/4x/guide/writing-middleware.mdx b/src/content/docs/en/4x/guide/writing-middleware.mdx index b896ee2e8f..7b60b76ed4 100644 --- a/src/content/docs/en/4x/guide/writing-middleware.mdx +++ b/src/content/docs/en/4x/guide/writing-middleware.mdx @@ -19,34 +19,7 @@ If the current middleware function does not end the request-response cycle, it m The following figure shows the elements of a middleware function call: -
- - - -
-Elements of a middleware function call - -
HTTP method for which the middleware function applies.
- -
- Path (route) for which the middleware function applies. -
- -
- The middleware function. -
- -
- Callback argument to the middleware function, called "next" by convention. -
- -
- HTTP [response](/api/response) argument to the middleware function, called "res" by convention. -
- -
HTTP [request](/api/request) argument to the middleware function, called "req" by convention.
-
-
+![Elements of a middleware function call](/images/express-mw.png) Starting with Express 5, middleware functions that return a Promise will call `next(value)` when they reject or throw an error. `next` will be called with either the rejected value or the thrown Error. diff --git a/src/plugins/rehype-accessible-tables.mjs b/src/plugins/rehype-accessible-tables.mjs index 672679b5dd..d606e91d5b 100644 --- a/src/plugins/rehype-accessible-tables.mjs +++ b/src/plugins/rehype-accessible-tables.mjs @@ -1,72 +1,159 @@ -import { visit, SKIP } from 'unist-util-visit'; +import { SKIP, visit } from 'unist-util-visit'; /** - * Rehype plugin that makes tables responsive and accessible: - * - Wraps each in a
for horizontal scroll on small viewports - * - Adds scope="col" to
- * - Adds scope="row" to + * Rehype plugin that: + * - Wraps each
inside
inside
in
+ * - Adds scope="col" to
+ * - Adds scope="row" to + * + * Supports both regular HAST elements and MDX JSX elements. + * + * NOTE: + * Process the table in separate passes so each step can safely modify the tree. + * Pass 1 and Pass 2 use the default traversal behavior, so `CONTINUE` does not + * need to be returned explicitly. Pass 3 uses `SKIP` after wrapping a table + * because its children have already been processed. */ -export function rehypeAccessibleTables() { +export default function rehypeAccessibleTables() { return (tree) => { - // Add scope="col" to th elements inside thead - visit(tree, 'element', (node) => { - if (node.tagName !== 'thead') return; - visit(node, 'element', (th) => { - if (th.tagName === 'th' && !th.properties.scope) { - th.properties.scope = 'col'; + const isSupportedNode = (node) => + node?.type === 'element' || + node?.type === 'mdxJsxFlowElement' || + node?.type === 'mdxJsxTextElement'; + + const getTagName = (node) => { + if (!isSupportedNode(node)) { + return null; + } + + return node.type === 'element' ? node.tagName : node.name; + }; + + const isTable = (node) => getTagName(node) === 'table'; + const isThead = (node) => getTagName(node) === 'thead'; + const isTbody = (node) => getTagName(node) === 'tbody'; + const isTr = (node) => getTagName(node) === 'tr'; + const isTh = (node) => getTagName(node) === 'th'; + + const hasScope = (node) => { + // HAST and MDX store attributes differently. + if (node.type === 'element') { + return node.properties?.scope != null; + } + + return node.attributes?.some((attribute) => attribute?.name === 'scope'); + }; + + const setScope = (node, scope) => { + // add the attribute differently depending on the node type. + if (node.type === 'element') { + node.properties ??= {}; + node.properties.scope = scope; + return; + } + + node.attributes ??= []; + + if (!node.attributes.some((attribute) => attribute?.name === 'scope')) { + node.attributes.push({ + type: 'mdxJsxAttribute', + name: 'scope', + value: scope, + }); + } + }; + + const isTableScroller = (node) => + node?.type === 'element' && + node.tagName === 'div' && + Array.isArray(node.properties?.className) && + node.properties.className.includes('table-scroller'); + + /* + * Remember which belongs to or . + * + * don't modify the tree during this pass. + * creating a little lookup table: + * → "col" + * → "row" + */ + const rowScopes = new WeakMap(); + + visit(tree, ['element', 'mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => { + let scope; + + if (isThead(node)) { + scope = 'col'; + } else if (isTbody(node)) { + scope = 'row'; + } + + if (!scope || !Array.isArray(node.children)) { + return; + } + + for (const child of node.children) { + if (isTr(child)) { + rowScopes.set(child, scope); } - }); + } }); - // Add scope="row" to th elements inside tbody - visit(tree, 'element', (node) => { - if (node.tagName !== 'tbody') return; - visit(node, 'element', (th) => { - if (th.tagName === 'th' && !th.properties.scope) { - th.properties.scope = 'row'; - } - }); + /* + * Add scope to / + * └── + * └── we recorded above. + */ + visit(tree, ['element', 'mdxJsxFlowElement', 'mdxJsxTextElement'], (node, index, parent) => { + if (!isTh(node) || !parent || hasScope(node)) { + return; + } + + const scope = rowScopes.get(parent); + + if (scope) { + setScope(node, scope); + } }); - // Wrap tables in a scrollable container - visit(tree, 'element', (node, index, parent) => { - if (node.tagName !== 'table') return; - if (!parent || index == null) return; + /* + * Wrap every table: + * + *
+ *
in
in
. + * + * In a normal HTML table: + * + *
+ * + * Therefore the parent of is the
...
+ * + */ + visit(tree, ['element', 'mdxJsxFlowElement', 'mdxJsxTextElement'], (node, index, parent) => { + if (!isTable(node)) { + return; + } - // Skip if already wrapped - if (parent.tagName === 'div' && parent.properties?.className?.includes('table-scroller')) + if (!parent || index == null || !Array.isArray(parent.children)) { return; + } + + if (isTableScroller(parent)) { + return SKIP; + } const wrapper = { type: 'element', tagName: 'div', - properties: { className: ['table-scroller'] }, + properties: { + className: ['table-scroller'], + }, children: [node], }; parent.children.splice(index, 1, wrapper); - // Skip the inserted wrapper so we don't revisit the table inside it - return [SKIP, index + 1]; - }); - }; -} -/** - * Astro integration wrapper — registers rehypeAccessibleTables after - * astro-expressive-code has already added its own rehype plugin, so - * the two don't interfere with each other in the pipeline. - */ -export function accessibleTablesIntegration() { - return { - name: 'rehype-accessible-tables', - hooks: { - 'astro:config:setup': ({ updateConfig }) => { - updateConfig({ - markdown: { - rehypePlugins: [rehypeAccessibleTables], - }, - }); - }, - }, + return SKIP; + }); }; } diff --git a/tests/unit/rehype-accessible-tables.test.mjs b/tests/unit/rehype-accessible-tables.test.mjs new file mode 100644 index 0000000000..c1dce485d8 --- /dev/null +++ b/tests/unit/rehype-accessible-tables.test.mjs @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; + +import test from 'node:test'; + +import rehypeAccessibleTables from '../../src/plugins/rehype-accessible-tables.mjs'; + +test('wraps mdx JSX tables in a responsive scroller', () => { + const tree = { + type: 'root', + children: [ + { + type: 'mdxJsxFlowElement', + name: 'table', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'thead', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'tr', + attributes: [], + children: [], + }, + ], + }, + ], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + assert.equal(tree.children[0].type, 'element'); + assert.equal(tree.children[0].tagName, 'div'); + assert.deepEqual(tree.children[0].properties.className, ['table-scroller']); + assert.equal(tree.children[0].children[0].type, 'mdxJsxFlowElement'); + assert.equal(tree.children[0].children[0].name, 'table'); +}); + +test('adds scope="col" to MDX JSX table headers in thead', () => { + const th = { + type: 'mdxJsxTextElement', + name: 'th', + attributes: [], + children: [], + }; + + const tree = { + type: 'root', + children: [ + { + type: 'mdxJsxFlowElement', + name: 'table', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'thead', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'tr', + attributes: [], + children: [th], + }, + ], + }, + ], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + assert.deepEqual(th.attributes, [ + { + type: 'mdxJsxAttribute', + name: 'scope', + value: 'col', + }, + ]); +}); + +test('adds scope="row" to MDX JSX table headers in tbody', () => { + const th = { + type: 'mdxJsxTextElement', + name: 'th', + attributes: [], + children: [], + }; + + const tree = { + type: 'root', + children: [ + { + type: 'mdxJsxFlowElement', + name: 'table', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'tbody', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'tr', + attributes: [], + children: [th], + }, + ], + }, + ], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + assert.deepEqual(th.attributes, [ + { + type: 'mdxJsxAttribute', + name: 'scope', + value: 'row', + }, + ]); +}); + +test('preserves an existing scope attribute', () => { + const th = { + type: 'mdxJsxTextElement', + name: 'th', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'scope', + value: 'colgroup', + }, + ], + children: [], + }; + + const tree = { + type: 'root', + children: [ + { + type: 'mdxJsxFlowElement', + name: 'table', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'thead', + attributes: [], + children: [ + { + type: 'mdxJsxFlowElement', + name: 'tr', + attributes: [], + children: [th], + }, + ], + }, + ], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + assert.deepEqual(th.attributes, [ + { + type: 'mdxJsxAttribute', + name: 'scope', + value: 'colgroup', + }, + ]); +}); + +test('wraps regular HAST HTML tables in a responsive scroller', () => { + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'table', + properties: {}, + children: [ + { + type: 'element', + tagName: 'thead', + properties: {}, + children: [ + { + type: 'element', + tagName: 'tr', + properties: {}, + children: [ + { + type: 'element', + tagName: 'th', + properties: {}, + children: [], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + const wrapper = tree.children[0]; + const table = wrapper.children[0]; + const th = table.children[0].children[0].children[0]; + + assert.equal(wrapper.type, 'element'); + assert.equal(wrapper.tagName, 'div'); + assert.deepEqual(wrapper.properties.className, ['table-scroller']); + assert.equal(table.tagName, 'table'); + assert.equal(th.properties.scope, 'col'); +}); + +test('adds scope="row" to regular HAST HTML table headers in tbody', () => { + const th = { + type: 'element', + tagName: 'th', + properties: {}, + children: [], + }; + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'table', + properties: {}, + children: [ + { + type: 'element', + tagName: 'tbody', + properties: {}, + children: [ + { + type: 'element', + tagName: 'tr', + properties: {}, + children: [th], + }, + ], + }, + ], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + assert.equal(th.properties.scope, 'row'); +}); + +test('wraps multiple tables independently', () => { + const createTable = () => ({ + type: 'element', + tagName: 'table', + properties: {}, + children: [], + }); + + const tree = { + type: 'root', + children: [createTable(), createTable()], + }; + + rehypeAccessibleTables()(tree); + + assert.equal(tree.children.length, 2); + + for (const child of tree.children) { + assert.equal(child.type, 'element'); + assert.equal(child.tagName, 'div'); + assert.deepEqual(child.properties.className, ['table-scroller']); + assert.equal(child.children[0].tagName, 'table'); + } +}); + +test('does not double-wrap an already wrapped table', () => { + const table = { + type: 'element', + tagName: 'table', + properties: {}, + children: [], + }; + + const tree = { + type: 'root', + children: [ + { + type: 'element', + tagName: 'div', + properties: { + className: ['table-scroller'], + }, + children: [table], + }, + ], + }; + + rehypeAccessibleTables()(tree); + + const wrapper = tree.children[0]; + + assert.equal(wrapper.tagName, 'div'); + assert.deepEqual(wrapper.properties.className, ['table-scroller']); + assert.equal(wrapper.children.length, 1); + assert.equal(wrapper.children[0].tagName, 'table'); +});