diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx index a44bf211c..b69109542 100644 --- a/src/components/markdown/Markdown.tsx +++ b/src/components/markdown/Markdown.tsx @@ -33,10 +33,13 @@ import { } from './MdComponents' type MarkdownRenderOptions = { + linkBaseUrl?: string preserveTabPanels?: boolean } export type MarkdownProps = { + /** Resolve package-relative links against their published source file. */ + linkBaseUrl?: string content?: string document?: MarkdownDocument chartEmbedSource?: ChartsCatalogEmbedSource @@ -46,6 +49,7 @@ export type MarkdownProps = { } export function Markdown({ + linkBaseUrl, content, document, chartEmbedSource, @@ -76,10 +80,11 @@ export function Markdown({ components: createMarkdownComponents({ preserveTabPanels, firstImageSrc, + linkBaseUrl, }), headingAnchors, }) - }, [renderDocument, preserveTabPanels, eagerFirstImage]) + }, [renderDocument, preserveTabPanels, eagerFirstImage, linkBaseUrl]) } const headingAnchors = { @@ -199,15 +204,6 @@ function CodeElement({ ) } -function LinkElement(props: React.AnchorHTMLAttributes) { - if (props.className?.includes('anchor-heading')) { - // eslint-disable-next-line jsx-a11y/anchor-has-content - return - } - - return -} - function TableElement({ className, ...props @@ -225,6 +221,15 @@ function TableElement({ function createMarkdownComponents( options: MarkdownRenderOptions & { firstImageSrc?: string } = {}, ) { + function LinkElement(props: React.AnchorHTMLAttributes) { + if (props.className?.includes('anchor-heading')) { + // eslint-disable-next-line jsx-a11y/anchor-has-content + return + } + + return + } + function MdCommentComponentWithOptions( props: React.ComponentProps, ) { diff --git a/src/components/markdown/MarkdownLink.tsx b/src/components/markdown/MarkdownLink.tsx index d2e54e5a9..0ddd7c6f2 100644 --- a/src/components/markdown/MarkdownLink.tsx +++ b/src/components/markdown/MarkdownLink.tsx @@ -62,9 +62,18 @@ function normalizeRoutableInternalPathname(pathname: string) { export function MarkdownLink({ href: hrefProp, + baseUrl, ...rest -}: HTMLProps) { - const href = hrefProp ?? '' +}: HTMLProps & { baseUrl?: string }) { + let href = hrefProp ?? '' + if (baseUrl && href && isRelativeLink(href)) { + try { + href = new URL(href, baseUrl).href + } catch { + // eslint-disable-next-line jsx-a11y/anchor-has-content + return + } + } if (!isSafeHref(href)) { // eslint-disable-next-line jsx-a11y/anchor-has-content @@ -94,7 +103,7 @@ export function MarkdownLink({ return ( diff --git a/src/routes/intent/registry/$packageName.$skillName.tsx b/src/routes/intent/registry/$packageName.$skillName.tsx index cf72adc92..8de195819 100644 --- a/src/routes/intent/registry/$packageName.$skillName.tsx +++ b/src/routes/intent/registry/$packageName.$skillName.tsx @@ -53,8 +53,8 @@ export const Route = createFileRoute( loaderDeps: ({ search }) => ({ version: search.version }), loader: async ({ params, deps, context: { queryClient } }) => { const name = decodePkgName(params.packageName) - const detail = queryClient.getQueryData( - intentPackageDetailQueryOptions(name).queryKey, + const detail = await queryClient.ensureQueryData( + intentPackageDetailQueryOptions(name), ) const latestVersion = detail?.versions[0]?.version ?? '' const activeVersion = deps.version ?? latestVersion @@ -139,6 +139,14 @@ function SkillDetailPage() { ) } + const sourceUrl = skill.skillPath + ? getSkillSourceUrl({ + packageName: pkgName, + skillPath: skill.skillPath, + version: activeVersion, + }) + : undefined + return ( <> {/* Skill meta */} @@ -162,11 +170,7 @@ function SkillDetailPage() { {skill.skillPath && (
{skillPage?.content ? ( - + ) : null}
diff --git a/tests/markdown-link.test.ts b/tests/markdown-link.test.ts index 931aee433..2ac1718b6 100644 --- a/tests/markdown-link.test.ts +++ b/tests/markdown-link.test.ts @@ -44,3 +44,63 @@ test('non-HTTPS Markdown links keep their existing behavior', () => { assert.doesNotMatch(html, /target=/) assert.doesNotMatch(html, /rel=/) }) + +test('package-relative references resolve from the published skill file', () => { + const baseUrl = + 'https://app.unpkg.com/%40tanstack/react-table@9.3.1/files/skills/getting-started/SKILL.md' + for (const [href, expected] of [ + [ + 'references/create-table-hook.md', + 'getting-started/references/create-table-hook.md', + ], + ['../table-state/SKILL.md', 'table-state/SKILL.md'], + [ + './references/setup.md#example', + 'getting-started/references/setup.md#example', + ], + ]) { + const html = renderToStaticMarkup( + createElement(MarkdownLink, { href, baseUrl }, 'Reference'), + ) + assert.ok( + html.includes( + `href="https://app.unpkg.com/%40tanstack/react-table@9.3.1/files/skills/${expected}"`, + ), + ) + assert.match(html, /target="_blank"/) + } +}) + +test('a package source does not replace absolute links or allow unsafe schemes', () => { + const baseUrl = 'https://app.unpkg.com/pkg@1.0.0/files/skills/setup/SKILL.md' + const external = renderToStaticMarkup( + createElement( + MarkdownLink, + { href: 'https://example.com/guide.md', baseUrl }, + 'Reference', + ), + ) + assert.match(external, /href="https:\/\/example.com\/guide.md"/) + const unsafe = renderToStaticMarkup( + createElement( + MarkdownLink, + { href: 'javascript:alert(1)', baseUrl }, + 'Reference', + ), + ) + assert.doesNotMatch(unsafe, /href=/) +}) + +test('malformed package-relative references render without a link', () => { + const html = renderToStaticMarkup( + createElement( + MarkdownLink, + { + href: String.raw`\\[bad`, + baseUrl: 'https://app.unpkg.com/pkg@1/files/skills/setup/SKILL.md', + }, + 'Reference', + ), + ) + assert.equal(html, '
Reference') +})