diff --git a/.changeset/tidy-melons-attack.md b/.changeset/tidy-melons-attack.md
new file mode 100644
index 00000000..e0ad1db1
--- /dev/null
+++ b/.changeset/tidy-melons-attack.md
@@ -0,0 +1,9 @@
+---
+"@cloudflare/nimbus-docs": patch
+---
+
+Fix authored-link normalization failing on JSX elements whose TypeScript parse drifts from the mdast node range.
+
+Match JSX elements by their start offset instead of their full range, skip elements without `href` attributes before the TypeScript round-trip, and reconcile attributes by name instead of index. This stops "ambiguous JSX range"/"ambiguous JSX attributes" failures on multi-line elements whose children contain JSX-like tokens (for example `{` inside a code block) or whose raw slice spans blockquote markers.
+
+Also skip normalization for plain `.md` files, which are not guaranteed to be valid MDX (legacy files can contain HTML comments or prose with `{key: value}`) and previously failed the build on an MDX parse error.
diff --git a/packages/nimbus-docs/src/_internal/authored-links.ts b/packages/nimbus-docs/src/_internal/authored-links.ts
index 8f134c87..65272e76 100644
--- a/packages/nimbus-docs/src/_internal/authored-links.ts
+++ b/packages/nimbus-docs/src/_internal/authored-links.ts
@@ -241,8 +241,8 @@ interface ParsedJsxRange {
sourceBase: number;
}
-function jsxRangeKey(start: number, end: number): string {
- return `${start}:${end}`;
+function jsxStartKey(start: number): string {
+ return `${start}`;
}
function staticHrefOffsets(
@@ -256,7 +256,16 @@ function staticHrefOffsets(
if (!Array.isArray(node.attributes)) {
fail("missing JSX attributes", source, sourceId, sourceStart);
}
- const key = jsxRangeKey(sourceStart, sourceStart + raw.length);
+ const hasHref = (
+ node.attributes as Array<{ type?: string; name?: unknown }>
+ ).some(
+ (attribute) =>
+ attribute?.type === "mdxJsxAttribute" &&
+ typeof attribute.name === "string" &&
+ isHref(node, attribute.name),
+ );
+ if (!hasHref) return [];
+ const key = jsxStartKey(sourceStart);
if (!parsedRanges.has(key)) {
const prefix = "const element = (";
const parsed = ts.createSourceFile(
@@ -273,13 +282,11 @@ function staticHrefOffsets(
ts.isJsxSelfClosingElement(candidate) ||
ts.isJsxFragment(candidate)
) {
- parsedRanges.set(
- jsxRangeKey(
- candidate.getStart(parsed) + sourceBase,
- candidate.getEnd() + sourceBase,
- ),
- { node: candidate, sourceFile: parsed, sourceBase },
- );
+ parsedRanges.set(jsxStartKey(candidate.getStart(parsed) + sourceBase), {
+ node: candidate,
+ sourceFile: parsed,
+ sourceBase,
+ });
}
ts.forEachChild(candidate, collect);
};
@@ -299,12 +306,9 @@ function staticHrefOffsets(
const properties = ts.isJsxElement(element)
? element.openingElement.attributes.properties
: element.attributes.properties;
- if (properties.length !== node.attributes.length) {
- fail("ambiguous JSX attributes", source, sourceId, sourceStart);
- }
const offsets: number[] = [];
- for (const [index, value] of node.attributes.entries()) {
+ for (const value of node.attributes) {
if (!value || typeof value !== "object") {
fail("invalid JSX attribute", source, sourceId, sourceStart);
}
@@ -313,32 +317,27 @@ function staticHrefOffsets(
name?: unknown;
value?: unknown;
};
- const property = properties[index]!;
if (attribute.type === "mdxJsxExpressionAttribute") {
- if (!ts.isJsxSpreadAttribute(property)) {
- fail(
- "ambiguous JSX spread expression",
- source,
- sourceId,
- property.getStart(parsed) + sourceBase,
- );
- }
continue;
}
if (
attribute.type !== "mdxJsxAttribute" ||
- typeof attribute.name !== "string" ||
- !ts.isJsxAttribute(property) ||
- property.name.getText(parsed) !== attribute.name
+ typeof attribute.name !== "string"
) {
- fail(
- "unsupported JSX attribute",
- source,
- sourceId,
- property.getStart(parsed) + sourceBase,
- );
+ fail("unsupported JSX attribute", source, sourceId, sourceStart);
+ }
+ const property = properties.find(
+ (candidate) =>
+ ts.isJsxAttribute(candidate) &&
+ candidate.name.getText(parsed) === attribute.name,
+ );
+ if (!property || !ts.isJsxAttribute(property)) {
+ // The TypeScript parse could not reconcile this attribute with the
+ // mdast node (for example, the raw slice spans blockquote markers that
+ // are not valid JSX). Skip it rather than failing the whole source.
+ continue;
}
if (attribute.value === null) {
if (property.initializer) {
@@ -416,6 +415,11 @@ export function normalizeAuthoredLinks(
try {
tree = mdxToMdast(source) as MdNode;
} catch (error) {
+ // Plain `.md` files are not guaranteed to be valid MDX (for example,
+ // legacy files with HTML comments or prose containing `{key: value}`).
+ // Leave them untouched rather than failing the build on a parse error.
+ // `.mdx` sources still fail closed.
+ if (options.sourceId?.endsWith(".md")) return source;
const detail = error instanceof Error ? error.message : String(error);
const location = detail.match(/^(\d+):(\d+):\s*/);
if (location) {
diff --git a/packages/nimbus-docs/test/authored-links.test.ts b/packages/nimbus-docs/test/authored-links.test.ts
index ed167f20..27d56e8b 100644
--- a/packages/nimbus-docs/test/authored-links.test.ts
+++ b/packages/nimbus-docs/test/authored-links.test.ts
@@ -180,3 +180,62 @@ test("rejects an invalid deployment base", () => {
);
}
});
+
+test("normalizes hrefs on JSX elements whose children contain JSX-like tokens", () => {
+ const source = `
+
+\`\`\`js
+{
+\`\`\`
+
+`;
+ assert.equal(
+ normalizeAuthoredLinks(source, { base: "/docs" }),
+ `
+
+\`\`\`js
+{
+\`\`\`
+
+`,
+ );
+});
+
+test("skips JSX elements whose raw slice spans blockquote markers", () => {
+ const source = `> Example:
+> \ttype="create"
+> \tpkg="vike@latest"
+> />`;
+ assert.equal(normalizeAuthoredLinks(source, { base: "/docs" }), source);
+});
+
+test("skips blockquote-mangled JSX hrefs instead of failing", () => {
+ const source = `> \thref="/card"
+> />`;
+ assert.equal(normalizeAuthoredLinks(source, { base: "/docs" }), source);
+});
+
+test("leaves plain Markdown files that are not valid MDX untouched", () => {
+ const source = `
+
+
+
+[Guide](/guide)`;
+ assert.equal(
+ normalizeAuthoredLinks(source, { base: "/docs", sourceId: "generated.md" }),
+ source,
+ );
+});
+
+test("still normalizes links in Markdown files that are valid MDX", () => {
+ const source = `[Guide](/guide)`;
+ assert.equal(
+ normalizeAuthoredLinks(source, {
+ base: "/docs",
+ sourceId: "generated.md",
+ }),
+ `[Guide](/docs/guide)`,
+ );
+});