Skip to content

Commit 14c2a00

Browse files
AhmadYasser1Fred K. Schott
authored andcommitted
fix(markdoc): resolve custom attributes on built-in table tag (#15457)
* fix(markdoc): sync custom attributes between tags and nodes with shared names In Markdoc, `table` exists as both a tag (`{% table %}`) and a node (the inner table structure). When users configure custom attributes on `nodes.table` or `tags.table`, the AST propagates those attributes to both the tag and node, but validation only checks the schema for each type independently. This caused "Invalid attribute" errors when attributes were declared on only one side. Add `syncTagNodeAttributes()` to automatically merge attribute declarations between tags and nodes that share the same name after config setup, so users can define attributes on either side. Fixes #14220 * chore: clarify why explicit types are needed on builtinTags/builtinNodes
1 parent a1ba219 commit 14c2a00

File tree

10 files changed

+178
-0
lines changed

10 files changed

+178
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@astrojs/markdoc': patch
3+
---
4+
5+
Fixes custom attributes on Markdoc's built-in `{% table %}` tag causing "Invalid attribute" validation errors.
6+
7+
In Markdoc, `table` exists as both a tag (`{% table %}`) and a node (the inner table structure). When users defined custom attributes on either `nodes.table` or `tags.table`, the attributes weren't synced to the counterpart, causing validation to fail on whichever side was missing the declaration.
8+
9+
The fix automatically syncs custom attribute declarations between tags and nodes that share the same name, so users can define attributes on either side and have them work correctly.

packages/integrations/markdoc/src/runtime.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export async function setupConfig(
3838
merged = mergeConfig(merged, HTML_CONFIG);
3939
}
4040

41+
syncTagNodeAttributes(merged);
4142
return merged;
4243
}
4344

@@ -54,6 +55,7 @@ export function setupConfigSync(
5455
merged = mergeConfig(merged, HTML_CONFIG);
5556
}
5657

58+
syncTagNodeAttributes(merged);
5759
return merged;
5860
}
5961

@@ -98,6 +100,46 @@ export function mergeConfig(
98100
};
99101
}
100102

103+
/**
104+
* Sync custom attributes between tags and nodes that share the same name.
105+
* In Markdoc, `table` exists as both a tag (`{% table %}`) and a node (the inner
106+
* table structure). Attributes on the tag propagate to the child node in the AST,
107+
* so both schemas must declare the same attributes for validation to pass.
108+
* When users configure attributes on only one side, this copies them to the other.
109+
*/
110+
function syncTagNodeAttributes(config: MergedConfig): void {
111+
// Markdoc's types don't have a string index signature, so we need the explicit
112+
// type to index with a dynamic key in the loop below
113+
const builtinTags: Record<string, any> = Markdoc.tags;
114+
const builtinNodes: Record<string, any> = Markdoc.nodes;
115+
116+
for (const name of Object.keys(builtinTags)) {
117+
if (!(name in builtinNodes)) continue;
118+
119+
const tagSchema = config.tags[name];
120+
const nodeSchema = config.nodes[name as NodeType];
121+
const tagAttrs = tagSchema?.attributes;
122+
const nodeAttrs = nodeSchema?.attributes;
123+
124+
// Nothing to sync if neither side has custom attributes
125+
if (!tagAttrs && !nodeAttrs) continue;
126+
127+
const mergedAttrs = { ...tagAttrs, ...nodeAttrs };
128+
129+
if (tagSchema) {
130+
config.tags[name] = { ...tagSchema, attributes: mergedAttrs };
131+
} else {
132+
config.tags[name] = { ...builtinTags[name], attributes: mergedAttrs };
133+
}
134+
135+
if (nodeSchema) {
136+
config.nodes[name as NodeType] = { ...nodeSchema, attributes: mergedAttrs };
137+
} else {
138+
config.nodes[name as NodeType] = { ...builtinNodes[name], attributes: mergedAttrs };
139+
}
140+
}
141+
}
142+
101143
/**
102144
* Check if a transform function respects the `render` property.
103145
* Astro's built-in transforms (like for headings) check `config.nodes?.X?.render`
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import markdoc from '@astrojs/markdoc';
2+
import { defineConfig } from 'astro/config';
3+
4+
export default defineConfig({
5+
integrations: [markdoc()],
6+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineMarkdocConfig } from '@astrojs/markdoc/config';
2+
3+
export default defineMarkdocConfig({
4+
nodes: {
5+
table: {
6+
attributes: {
7+
background: {
8+
type: String,
9+
matches: ['default', 'transparent'],
10+
},
11+
},
12+
},
13+
},
14+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "@test/markdoc-render-table-attrs",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"@astrojs/markdoc": "workspace:*",
7+
"astro": "workspace:*"
8+
}
9+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineCollection } from 'astro:content';
2+
import { glob } from 'astro/loaders';
3+
4+
const blog = defineCollection({
5+
loader: glob({ pattern: '**/*.mdoc', base: './src/content/blog' }),
6+
});
7+
8+
export const collections = {
9+
blog,
10+
};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
title: Post with table attributes
3+
---
4+
5+
## Table with attributes
6+
7+
{% table background="default" %}
8+
* Feature
9+
* Supported
10+
---
11+
* Custom attributes
12+
* Yes
13+
{% /table %}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
import { getEntry, render } from "astro:content";
3+
4+
const post = await getEntry('blog', 'with-table-attrs');
5+
const { Content } = await render(post);
6+
---
7+
8+
<!DOCTYPE html>
9+
<html lang="en">
10+
<head>
11+
<meta charset="UTF-8">
12+
<title>Content</title>
13+
</head>
14+
<body>
15+
<Content />
16+
</body>
17+
</html>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
import { parseHTML } from 'linkedom';
4+
import { loadFixture } from '../../../astro/test/test-utils.js';
5+
6+
async function getFixture() {
7+
return await loadFixture({
8+
root: new URL('./fixtures/render-table-attrs/', import.meta.url),
9+
});
10+
}
11+
12+
describe('Markdoc - table attributes', () => {
13+
describe('build', () => {
14+
it('renders table with custom attributes without validation errors', async () => {
15+
const fixture = await getFixture();
16+
await fixture.build();
17+
18+
const html = await fixture.readFile('/index.html');
19+
const { document } = parseHTML(html);
20+
21+
const th = document.querySelector('th');
22+
assert.ok(th, 'table header should exist');
23+
assert.equal(th.textContent, 'Feature');
24+
25+
const td = document.querySelector('td');
26+
assert.equal(td.textContent, 'Custom attributes');
27+
});
28+
});
29+
30+
describe('dev', () => {
31+
it('renders table with custom attributes without validation errors', async () => {
32+
const fixture = await getFixture();
33+
const server = await fixture.startDevServer();
34+
35+
const res = await fixture.fetch('/');
36+
const html = await res.text();
37+
const { document } = parseHTML(html);
38+
39+
const th = document.querySelector('th');
40+
assert.ok(th, 'table header should exist');
41+
assert.equal(th.textContent, 'Feature');
42+
43+
const td = document.querySelector('td');
44+
assert.equal(td.textContent, 'Custom attributes');
45+
46+
await server.stop();
47+
});
48+
});
49+
});

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)