Skip to content
47 changes: 40 additions & 7 deletions app/pages/package/[...package].vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,15 @@ const { data: readmeData } = useLazyFetch<ReadmeResponse>(
const version = requestedVersion.value
return version ? `${base}/v/${version}` : base
},
{ default: () => ({ html: '', playgroundLinks: [], toc: [] }) },
{ default: () => ({ html: '', md: '', playgroundLinks: [], toc: [] }) },
)

//copy README file as Markdown
const { copied: copiedReadme, copy: copyReadme } = useClipboard({
source: () => readmeData.value?.md ?? '',
copiedDuring: 2000,
})

// Track active TOC item based on scroll position
const tocItems = computed(() => readmeData.value?.toc ?? [])
const { activeId: activeTocId, scrollToHeading } = useActiveTocItem(tocItems)
Expand Down Expand Up @@ -1130,12 +1136,39 @@ onKeyStroke(
</a>
</h2>
<ClientOnly>
<ReadmeTocDropdown
v-if="readmeData?.toc && readmeData.toc.length > 1"
:toc="readmeData.toc"
:active-id="activeTocId"
:scroll-to-heading="scrollToHeading"
/>
<div class="flex items-center gap-2">
<!-- Copy readme as Markdown button -->
<TooltipApp
v-if="readmeData?.md"
:text="$t('package.readme.copy_as_markdown')"
position="bottom"
>
<button
type="button"
@click="copyReadme()"
class="px-2 py-1.5 font-mono text-xs rounded transition-colors duration-150 inline-flex items-center gap-1.5"
:class="
copiedReadme ? 'text-accent bg-accent/10' : 'text-fg-subtle bg-bg hover:text-fg'
"
:aria-label="
copiedReadme ? $t('common.copied') : $t('package.readme.copy_as_markdown')
"
>
<span
:class="copiedReadme ? 'i-carbon:checkmark' : 'i-simple-icons:markdown'"
class="size-3"
aria-hidden="true"
/>
{{ copiedReadme ? $t('common.copied') : $t('common.copy') }}
Copy link

@ghostdevv ghostdevv Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this "copy" text looks nicer title case (i.e. Copy not copy) but I think that would be inconsistent with the rest of the site

</button>
</TooltipApp>
<ReadmeTocDropdown
v-if="readmeData?.toc && readmeData.toc.length > 1"
:toc="readmeData.toc"
:active-id="activeTocId"
:scroll-to-heading="scrollToHeading"
/>
</div>
</ClientOnly>
</div>

Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@
"important": "Wichtig",
"warning": "Warnung",
"caution": "Vorsicht"
}
},
"copy_as_markdown": "README als Markdown kopieren"
},
"provenance_section": {
"title": "Herkunft",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@
"important": "Important",
"warning": "Warning",
"caution": "Caution"
}
},
"copy_as_markdown": "Copy README as Markdown"
},
"provenance_section": {
"title": "Provenance",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@
"title": "Léeme",
"no_readme": "No hay README disponible.",
"view_on_github": "Ver en GitHub",
"toc_title": "Índice"
"toc_title": "Índice",
"copy_as_markdown": "Copiar README como Markdown"
},
"keywords_title": "Palabras clave",
"compatibility": "Compatibilidad",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@
"title": "Readme",
"no_readme": "Aucun README disponible.",
"view_on_github": "Voir sur GitHub",
"toc_title": "Sommaire"
"toc_title": "Sommaire",
"copy_as_markdown": "Copier le README en markdown"
},
"keywords_title": "Mots-clés",
"compatibility": "Compatibilité",
Expand Down
3 changes: 2 additions & 1 deletion i18n/locales/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@
"important": "Importante",
"warning": "Avvertenza",
"caution": "Cautela"
}
},
"copy_as_markdown": "Copia README come Markdown"
},
"provenance_section": {
"title": "Provenienza",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@
"important": "Wichtig",
"warning": "Warnung",
"caution": "Vorsicht"
}
},
"copy_as_markdown": "README als Markdown kopieren"
},
"provenance_section": {
"title": "Herkunft",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@
"important": "Important",
"warning": "Warning",
"caution": "Caution"
}
},
"copy_as_markdown": "Copy README as Markdown"
},
"provenance_section": {
"title": "Provenance",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@
"important": "Important",
"warning": "Warning",
"caution": "Caution"
}
},
"copy_as_markdown": "Copy README as Markdown"
},
"provenance_section": {
"title": "Provenance",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/es-419.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@
"title": "Léame",
"no_readme": "No hay README disponible.",
"view_on_github": "Ver en GitHub",
"toc_title": "Índice"
"toc_title": "Índice",
"copy_as_markdown": "Copiar README como Markdown"
},
"keywords_title": "Palabras clave",
"compatibility": "Compatibilidad",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@
"title": "Léeme",
"no_readme": "No hay README disponible.",
"view_on_github": "Ver en GitHub",
"toc_title": "Índice"
"toc_title": "Índice",
"copy_as_markdown": "Copiar README como Markdown"
},
"keywords_title": "Palabras clave",
"compatibility": "Compatibilidad",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@
"title": "Readme",
"no_readme": "Aucun README disponible.",
"view_on_github": "Voir sur GitHub",
"toc_title": "Sommaire"
"toc_title": "Sommaire",
"copy_as_markdown": "Copier le README en markdown"
},
"keywords_title": "Mots-clés",
"compatibility": "Compatibilité",
Expand Down
3 changes: 2 additions & 1 deletion lunaria/files/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@
"important": "Importante",
"warning": "Avvertenza",
"caution": "Cautela"
}
},
"copy_as_markdown": "Copia README come Markdown"
},
"provenance_section": {
"title": "Provenienza",
Expand Down
3 changes: 2 additions & 1 deletion server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export async function renderReadmeHtml(
packageName: string,
repoInfo?: RepositoryInfo,
): Promise<ReadmeResponse> {
if (!content) return { html: '', playgroundLinks: [], toc: [] }
if (!content) return { html: '', md: '', playgroundLinks: [], toc: [] }

const shiki = await getShikiHighlighter()
const renderer = new marked.Renderer()
Expand Down Expand Up @@ -455,6 +455,7 @@ ${html}

return {
html: convertToEmoji(sanitized),
md: content,
playgroundLinks: collectedLinks,
toc,
}
Expand Down
2 changes: 2 additions & 0 deletions shared/types/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface TocItem {
export interface ReadmeResponse {
/** Rendered HTML content */
html: string
/** Original markdown content */
md: string
/** Extracted playground/demo links */
playgroundLinks: PlaygroundLink[]
/** Table of contents extracted from headings */
Expand Down
21 changes: 21 additions & 0 deletions test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,24 @@ describe('Markdown File URL Resolution', () => {
})
})
})

describe('Markdown Content Extraction', () => {
describe('Markdown', () => {
it('returns original markdown content unchanged', async () => {
const markdown = `# Title\n\nSome **bold** text and a [link](https://example.com).`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.md).toBe(markdown)
})
})
describe('HTML', () => {
it('returns sanitized html', async () => {
const markdown = `# Title\n\nSome **bold** text and a [link](https://example.com).`
const result = await renderReadmeHtml(markdown, 'test-pkg')

expect(result.html).toBe(`<h3 id="user-content-title" data-level="1">Title</h3>
<p>Some <strong>bold</strong> text and a <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">link</a>.</p>
`)
})
})
})
Loading