diff --git a/packages/ui/package.json b/packages/ui/package.json
index 1b17bc8744..0c43476e6a 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -50,6 +50,7 @@
"@remixicon/react": "^4.6.0",
"@tanstack/react-table": "^8.21.3",
"clsx": "^2.1.1",
+ "marked": "^15.0.12",
"react-aria": "~3.48.0",
"react-aria-components": "~1.17.0",
"react-stately": "~3.46.0",
diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx
index 90e107c51c..0098fa00d4 100644
--- a/packages/ui/src/components/Header/Header.tsx
+++ b/packages/ui/src/components/Header/Header.tsx
@@ -21,53 +21,39 @@ import { HeaderNav } from './HeaderNav';
import { useDefinition } from '../../hooks/useDefinition';
import { HeaderDefinition } from './definition';
import { Container } from '../Container';
+import { Lexer } from 'marked';
import { Link } from '../Link';
import { Fragment } from 'react/jsx-runtime';
-const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g;
// Reject javascript:/vbscript:/data: URIs to prevent XSS via description links.
const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i;
/**
- * Renders a plain-text string that may contain inline Markdown links of the
- * form `[label](href)` as an array of React nodes (strings and Link elements).
- * Links with unsafe URL schemes (javascript:, vbscript:, data:) are rendered
- * as plain text instead.
+ * Renders a plain-text string that may contain inline Markdown links as an
+ * array of React nodes (strings and Link elements). Links with unsafe URL
+ * schemes (javascript:, vbscript:, data:) are rendered as plain text instead.
*
- * We intentionally avoid `react-markdown` here: that package is ESM-only
- * (v8+), which breaks Jest in Node-role packages that transitively import
- * `@backstage/ui` (e.g. via `core-app-api`). Since the Header description only
- * needs inline link support, a small regex-based parser is sufficient and keeps
- * this package free of ESM dependencies.
+ * We use `marked`'s `Lexer.lexInline()` rather than `react-markdown` because
+ * `react-markdown` v8+ is ESM-only, which breaks Jest in Node-role packages
+ * that transitively import `@backstage/ui` (e.g. via `core-app-api`). `marked`
+ * ships CommonJS, has zero dependencies, and its inline lexer gives us a clean
+ * token model without needing to maintain a custom regex.
*/
function renderInlineMarkdown(text: string): React.ReactNode[] {
- const parts: React.ReactNode[] = [];
- let last = 0;
- let match: RegExpExecArray | null;
- INLINE_LINK_RE.lastIndex = 0;
- while ((match = INLINE_LINK_RE.exec(text)) !== null) {
- if (match.index > last) {
- parts.push(text.slice(last, match.index));
- }
- // Trim leading whitespace/control chars before scheme check to prevent
- // bypass via inputs like " javascript:alert(1)".
- const href = match[2].trimStart();
- const label = match[1];
- if (UNSAFE_HREF_RE.test(href)) {
- parts.push(label);
- } else {
- parts.push(
-
- {label}
- ,
+ return Lexer.lexInline(text).map((token, i) => {
+ if (token.type === 'link') {
+ // Trim leading whitespace/control chars before scheme check to prevent
+ // bypass via inputs like " javascript:alert(1)".
+ const href = token.href.trimStart();
+ if (UNSAFE_HREF_RE.test(href)) return token.text;
+ return (
+
+ {token.text}
+
);
}
- last = match.index + match[0].length;
- }
- if (last < text.length) {
- parts.push(text.slice(last));
- }
- return parts;
+ return token.raw;
+ });
}
/**
diff --git a/yarn.lock b/yarn.lock
index 6802845bac..bbd007be7f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7983,6 +7983,7 @@ __metadata:
eslint-plugin-storybook: "npm:^10.3.3"
glob: "npm:^13.0.0"
globals: "npm:^17.0.0"
+ marked: "npm:^15.0.12"
react: "npm:^18.0.2"
react-aria: "npm:~3.48.0"
react-aria-components: "npm:~1.17.0"