Skip to content
Open
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
4 changes: 2 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +45,7 @@ export default defineConfig({
behavior: 'wrap',
},
],
rehypeAccessibleTables,
],
}),
},
Expand All @@ -68,7 +69,6 @@ export default defineConfig({
borderWidth: 'var(--border-width-1)',
},
}),
accessibleTablesIntegration(),
mdx(),
icon(),
react(),
Expand Down
48 changes: 48 additions & 0 deletions docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<th scope="col">Method</th>
```

and a wrapper for horizontal scrolling:

```html
<div class="table-scroller">
<table>
...
</table>
</div>
```

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.
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ Português
Prunus
Riva
SAWARATSUKI
scroller
sder
secr
sendfile
Expand Down
4 changes: 0 additions & 4 deletions src/content/blog/2025-03-31-v5-1-latest-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<div markdown="1" style="overflow-x: auto; max-width: 100%;">

| 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 | | | |

</div>

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
Expand Down
29 changes: 1 addition & 28 deletions src/content/docs/en/4x/guide/writing-middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<div class="table-scroller">
<table id="mw-fig">
<tr><td id="mw-fig-imgcell">
<img src="/images/express-mw.png" alt="Elements of a middleware function call" id="mw-fig-img" />
</td>
<td class="mw-fig-callouts">
<div class="callout" id="callout1">HTTP method for which the middleware function applies.</div>

<div class="callout" id="callout2">
Path (route) for which the middleware function applies.
</div>

<div class="callout" id="callout3">
The middleware function.
</div>

<div class="callout" id="callout4">
Callback argument to the middleware function, called "next" by convention.
</div>

<div class="callout" id="callout5">
HTTP [response](/api/response) argument to the middleware function, called "res" by convention.
</div>

<div class="callout" id="callout6">HTTP [request](/api/request) argument to the middleware function, called "req" by convention.</div>
</td></tr>
</table>
</div>
![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.

Expand Down
187 changes: 137 additions & 50 deletions src/plugins/rehype-accessible-tables.mjs
Original file line number Diff line number Diff line change
@@ -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 <table> in a <div class="table-scroller"> for horizontal scroll on small viewports
* - Adds scope="col" to <th> inside <thead>
* - Adds scope="row" to <th> inside <tbody>
* Rehype plugin that:
* - Wraps each <table> in <div class="table-scroller">
* - Adds scope="col" to <th> in <thead>
* - Adds scope="row" to <th> in <tbody>
*
* 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 <tr> belongs to <thead> or <tbody>.
*
* don't modify the tree during this pass.
* creating a little lookup table:
* <tr object> → "col"
* <tr object> → "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 <th>.
*
* In a normal HTML table:
*
* <thead>/<tbody>
* └── <tr>
* └── <th>
*
* Therefore the parent of <th> is the <tr> 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:
*
* <div class="table-scroller">
* <table>...</table>
* </div>
*/
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;
});
};
}
Loading
Loading