From a4c2c93ed37c81ad2199f861495210522c972d2f Mon Sep 17 00:00:00 2001 From: mikouaji Date: Mon, 16 Feb 2026 18:39:07 +0100 Subject: [PATCH 1/6] chore: move out escapeHtml function to shared util, update imports --- server/utils/docs/render.ts | 3 ++- server/utils/docs/text.ts | 15 +-------------- shared/utils/html.ts | 12 ++++++++++++ test/unit/server/utils/docs/text.spec.ts | 8 ++------ 4 files changed, 17 insertions(+), 21 deletions(-) create mode 100644 shared/utils/html.ts diff --git a/server/utils/docs/render.ts b/server/utils/docs/render.ts index b7b6b6c1f..8acd56e9d 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -10,7 +10,8 @@ import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc' import { highlightCodeBlock } from '../shiki' import { formatParam, formatType, getNodeSignature } from './format' import { groupMergedByKind } from './processing' -import { createSymbolId, escapeHtml, parseJsDocLinks, renderMarkdown } from './text' +import { escapeHtml } from '#shared/utils/html' +import { createSymbolId, parseJsDocLinks, renderMarkdown } from './text' import type { MergedSymbol, SymbolLookup } from './types' // ============================================================================= diff --git a/server/utils/docs/text.ts b/server/utils/docs/text.ts index 7cb574903..553820d5a 100644 --- a/server/utils/docs/text.ts +++ b/server/utils/docs/text.ts @@ -9,6 +9,7 @@ import { highlightCodeBlock } from '../shiki' import type { SymbolLookup } from './types' +import { escapeHtml } from '#shared/utils/html' /** * Strip ANSI escape codes from text. @@ -21,20 +22,6 @@ export function stripAnsi(text: string): string { return text.replace(ANSI_PATTERN, '') } -/** - * Escape HTML special characters. - * - * @internal Exported for testing - */ -export function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} - /** * Clean up symbol names by stripping esm.sh prefixes. * diff --git a/shared/utils/html.ts b/shared/utils/html.ts new file mode 100644 index 000000000..c60f4c482 --- /dev/null +++ b/shared/utils/html.ts @@ -0,0 +1,12 @@ +/** + * Escape HTML special characters to prevent XSS and ensure safe embedding. + * Handles text content and attribute values (`&`, `<`, `>`, `"`, `'`). + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/test/unit/server/utils/docs/text.spec.ts b/test/unit/server/utils/docs/text.spec.ts index a928d45fe..db14e848c 100644 --- a/test/unit/server/utils/docs/text.spec.ts +++ b/test/unit/server/utils/docs/text.spec.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' import * as fc from 'fast-check' -import { - escapeHtml, - parseJsDocLinks, - renderMarkdown, - stripAnsi, -} from '../../../../../server/utils/docs/text' +import { escapeHtml } from '#shared/utils/html' +import { parseJsDocLinks, renderMarkdown, stripAnsi } from '../../../../../server/utils/docs/text' import type { SymbolLookup } from '../../../../../server/utils/docs/types' describe('stripAnsi', () => { From a661cb0c0a0302ed802be2dc99aab1f6906a62df Mon Sep 17 00:00:00 2001 From: mikouaji Date: Mon, 16 Feb 2026 18:39:44 +0100 Subject: [PATCH 2/6] feat: add new htmlToMarkdown function --- app/utils/html-to-markdown.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 app/utils/html-to-markdown.ts diff --git a/app/utils/html-to-markdown.ts b/app/utils/html-to-markdown.ts new file mode 100644 index 000000000..514dddc47 --- /dev/null +++ b/app/utils/html-to-markdown.ts @@ -0,0 +1,21 @@ +import { parseFragment } from 'parse5' +import { fromParse5 } from 'hast-util-from-parse5' +import { toMdast } from 'hast-util-to-mdast' +import { toMarkdown as mdastToMarkdown } from 'mdast-util-to-markdown' +import { gfmTableToMarkdown } from 'mdast-util-gfm-table' + +export interface HtmlToMarkdownOptions { + /** Whether to pad table columns to equal width (default: `true`). */ + tablePipeAlign?: boolean +} + +/** + * Convert an HTML string to Markdown + */ +export function htmlToMarkdown(html: string, options: HtmlToMarkdownOptions = {}): string { + const { tablePipeAlign = true } = options + const dom = parseFragment(html) + const hast = fromParse5(dom) + const mdast = toMdast(hast) + return mdastToMarkdown(mdast, { extensions: [gfmTableToMarkdown({ tablePipeAlign })] }) +} From e7ac4273fc3fa76088dce4316cbdffab679bc558 Mon Sep 17 00:00:00 2001 From: mikouaji Date: Mon, 16 Feb 2026 18:42:31 +0100 Subject: [PATCH 3/6] feat: allow copying comparison grid as markdown, translations --- app/pages/compare.vue | 119 +++++++++++++++++++++++++++++++++++++-- i18n/locales/en.json | 1 + i18n/locales/pl-PL.json | 1 + i18n/schema.json | 3 + lunaria/files/en-GB.json | 1 + lunaria/files/en-US.json | 1 + lunaria/files/pl-PL.json | 1 + 7 files changed, 122 insertions(+), 5 deletions(-) diff --git a/app/pages/compare.vue b/app/pages/compare.vue index ac2fbcf60..3ef886866 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -8,6 +8,8 @@ definePageMeta({ const router = useRouter() const canGoBack = useCanGoBack() +const { copied, copy } = useClipboard({ copiedDuring: 2000 }) +const gridRef = useTemplateRef('gridRef') // Sync packages with URL query param (stable ref - doesn't change on other query changes) const packagesParam = useRouteQuery('packages', '', { mode: 'replace' }) @@ -79,6 +81,57 @@ const gridHeaders = computed(() => gridColumns.value.map(col => (col.version ? `${col.name}@${col.version}` : col.name)), ) +function copyComparisonGridAsMd() { + const grid = gridRef.value?.querySelector('.comparison-grid') + if (!grid) return + const md = gridToMarkdown(grid as HTMLElement) + copy(md) +} + +/* + * Convert the comparison grid DOM to a Markdown table. + * We build a proper HTML from the grid structure and delegate to `htmlToMarkdown` for the actual conversion. + */ +function gridToMarkdown(gridEl: HTMLElement): string { + const children = Array.from(gridEl.children) + const headerRow = children[0] + const dataRows = children.slice(1) + + if (!headerRow || dataRows.length === 0) return '' + + const headerCells = Array.from(headerRow.children).slice(1) + if (headerCells.length === 0) return '' + + const ths = headerCells.map(cell => { + const link = cell.querySelector('a') + if (link) { + const href = link.getAttribute('href') || '' + const absoluteHref = /^https?:\/\/|^\/\//.test(href) ? href : `${NPMX_SITE}${href}` + return `` + } + return `` + }) + + const trs = dataRows.map(row => { + const rowChildren = Array.from(row.children) + const label = rowChildren[0]?.textContent?.trim() || '' + const valueCells = rowChildren.slice(1) + const tds = [label, ...valueCells.map(cell => cell.textContent?.trim() || '-')] + .map(v => ``) + .join('') + return `${tds}` + }) + + const tableHtml = [ + '
${escapeHtml(link.textContent?.trim() || '')}${escapeHtml(cell.textContent?.trim() || '')}${escapeHtml(v)}
', + `${ths.join('')}`, + `${trs.join('')}`, + '
Metric
', + ].join('') + + return htmlToMarkdown(tableHtml, { tablePipeAlign: false }) +} + useSeoMeta({ title: () => packages.value.length > 0 @@ -193,9 +246,30 @@ useSeoMeta({
-

- {{ $t('compare.packages.section_comparison') }} -

+
+

+ {{ $t('compare.packages.section_comparison') }} +

+ + +
p !== null)"> -