Skip to content

feat: module replacements v3#2068

Draft
gameroman wants to merge 71 commits intonpmx-dev:mainfrom
gameroman:module-replacements-v3
Draft

feat: module replacements v3#2068
gameroman wants to merge 71 commits intonpmx-dev:mainfrom
gameroman:module-replacements-v3

Conversation

@gameroman
Copy link
Contributor

@gameroman gameroman commented Mar 13, 2026

Module replacements v3

@vercel
Copy link

vercel bot commented Mar 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 26, 2026 5:50pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 26, 2026 5:50pm
npmx-lunaria Ignored Ignored Mar 26, 2026 5:50pm

Request Review

@codecov
Copy link

codecov bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 67.50000% with 13 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/Package/Replacement.vue 64.28% 9 Missing and 1 partial ⚠️
app/composables/useModuleReplacement.ts 0.00% 2 Missing ⚠️
app/components/Compare/ReplacementSuggestion.vue 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@gameroman
Copy link
Contributor Author

I broke everything again

(╯‵□′)╯︵┻━┻

@gameroman
Copy link
Contributor Author

@coderabbitai

+ Consider no dep? box does not render on /compare page anymore. Why and what e2e test do you recommend?

@gameroman gameroman marked this pull request as draft March 26, 2026 13:17
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 26, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== server/api/replacements/[...pkg].get.ts ==="
cat "server/api/replacements/[...pkg].get.ts"

echo "==="
echo "=== useCompareReplacements composable ==="
fd -t f "useCompareReplacements*" --exec cat {}

echo "==="
echo "=== ReplacementSuggestion.vue ==="
fd -t f "ReplacementSuggestion.vue" --exec cat {}

echo "==="
echo "=== Compare page ==="
fd -t f "compare*" --search-path app/pages --exec echo "FILE: {}" \; --exec cat {}

echo "==="
echo "=== useModuleReplacement composable ==="
fd -t f "useModuleReplacement*" --exec cat {}

echo "==="
echo "=== module-replacements v3 manifest structure ==="
cat /tmp/package/dist/esm/main.js 2>/dev/null | head -50
# Try to check the manifests structure
ls /tmp/package/dist/esm/ 2>/dev/null

Length of output: 22490


@gameroman Here's the root cause and the fix.


Root cause: type mismatch in useCompareReplacements.ts

The server API (server/api/replacements/[...pkg].get.ts) now returns a wrapped object:

return { mapping, replacement }
// → { mapping: ModuleReplacementMapping; replacement: ModuleReplacement }

But useCompareReplacements.ts still fetches it as if it returns a bare ModuleReplacement:

const replacement = await $fetch<ModuleReplacement | null>(`/api/replacements/${name}`)
//                               ^^^^^^^^^^^^^^^^^ wrong — actual shape is { mapping, replacement }

So at runtime replacements.value.get(pkg) holds { mapping: ..., replacement: ... } — an object — not a ModuleReplacement. When the filter evaluates s.replacement.type, it gets undefined, so both noDepSuggestions and infoSuggestions are always empty, and the box never renders.

(Note: useModuleReplacement.ts used on the package page already uses the correct type { mapping: ModuleReplacementMapping; replacement: ModuleReplacement } | null, which is why the package page still works.)


Fix

In app/composables/useCompareReplacements.ts, update the $fetch call to unwrap the nested .replacement:

-import type { ModuleReplacement } from 'module-replacements'
+import type { ModuleReplacement, ModuleReplacementMapping } from 'module-replacements'

 // ...inside fetchReplacements:
-            const replacement = await $fetch<ModuleReplacement | null>(`/api/replacements/${name}`)
-            return { name, replacement }
+            const result = await $fetch<{ mapping: ModuleReplacementMapping; replacement: ModuleReplacement } | null>(`/api/replacements/${name}`)
+            return { name, replacement: result?.replacement ?? null }

Recommended e2e test

Add to test/e2e/compare.spec.ts (or a new test/e2e/compare-replacements.spec.ts):

import { expect, test } from './test-utils'

test.describe('Compare page - replacement suggestions', () => {
  test('shows "Consider no dep?" box for packages with native/simple replacements', async ({
    page,
    goto,
  }) => {
    // is-odd has a 'simple' replacement in module-replacements v3
    await goto('/compare?packages=is-odd,is-even', { waitUntil: 'hydration' })

    // The suggestion box should appear after client-side fetch resolves
    // Button text comes from $t('package.replacement.consider_no_dep')
    const considerNoDepButton = page.getByRole('button', { name: /consider no dep/i })
    await expect(considerNoDepButton).toBeVisible({ timeout: 15_000 })
  })

  test('does not show "Consider no dep?" box for packages without replacements', async ({
    page,
    goto,
  }) => {
    await goto('/compare?packages=nuxt,vue', { waitUntil: 'hydration' })

    const considerNoDepButton = page.getByRole('button', { name: /consider no dep/i })
    await expect(considerNoDepButton).not.toBeVisible({ timeout: 10_000 })
  })
})

Make sure test/fixtures/npm-registry/packuments/is-odd.json and is-even.json already exist (they appear to, based on the test suite context). The /api/replacements/ server route runs on Nuxt directly and reads from the installed module-replacements package, so no mock intercept is needed for it.


🧠 Learnings used
Learnt from: gameroman
Repo: npmx-dev/npmx.dev PR: 0
File: :0-0
Timestamp: 2026-03-20T22:32:30.457Z
Learning: In the npmx.dev repository, the server-side test fixture interception for `data.jsdelivr.com` (jsDelivr Data API) in `modules/runtime/server/cache.ts` (`getMockForUrl`) always returns a hardcoded stub (only `package.json`, `index.js`, `README.md`) and does NOT read from `test/fixtures/jsdelivr/*.json` fixture files. Per-package jsDelivr fixture files (used by `mock-routes.cjs` for Playwright browser-level interception) are therefore ignored for server-side API routes. Fixing the badge types e2e test requires adding an async `handleJsdelivrDataApi` function in `cache.ts` that reads from storage (similar to `handleFastNpmMeta`), and wiring it into `fetchFromFixtures` so server-side fetches to `data.jsdelivr.com` also benefit from per-package fixtures.

Learnt from: gameroman
Repo: npmx-dev/npmx.dev PR: 2135
File: test/e2e/interactions.spec.ts:89-90
Timestamp: 2026-03-19T13:44:54.800Z
Learning: In the npmx.dev repository, E2E tests under `test/e2e/` that hit internal API routes (e.g. `/api/registry/analysis/`, `/api/registry/install-size/`) are backed by static JSON fixtures in `test/fixtures/npm-registry/packuments/`. The `nuxt/kit` packument fixture pins `latest` to `4.3.0` with `"type": "module"` and `.mjs` exports, so asserting `moduleFormat === 'esm'` for `nuxt/kit` in E2E tests is stable and not flaky. Do not suggest weakening such assertions on the grounds of live-data brittleness.

Learnt from: gameroman
Repo: npmx-dev/npmx.dev PR: 2173
File: server/api/registry/badge/[type]/[...pkg].get.ts:376-376
Timestamp: 2026-03-20T21:28:38.095Z
Learning: In the npmx.dev repository, `fetchPackageWithTypesAndFiles` in `server/utils/file-tree.ts` requests `${NPM_REGISTRY}/${packageName}/latest` from the npm registry. The E2E test mock (`matchNpmRegistry` in `test/fixtures/mock-routes.cjs`) strips the version suffix and returns the full packument fixture. Because a packument's version-specific fields (like `exports`, `type`, `main`) live under `versions["x.y.z"]` and not at the packument root, `pkg.exports` will be `undefined` when the mock response is used. For file-based types detection tests that exercise `fetchPackageWithTypesAndFiles`, the packument fixture must hoist `exports`, `type`, `version`, and `main` to the root level of the JSON file.

Comment on lines +31 to +86
<template #replacement>
<code v-if="replacement.description">
{{ replacement.description }}
</code>
<span v-else>{{ replacement.id }}</span>
</template>
<template #nodeVersion>
{{ nodeVersion }}
</template>
</i18n-t>
<i18n-t
v-else-if="replacement.type === 'native'"
keypath="package.replacement.native_no_version"
scope="global"
>
<template #replacement>
<code v-if="replacement.description">
{{ replacement.description }}
</code>
<span v-else>{{ replacement.id }}</span>
</template>
</i18n-t>
<div v-else-if="replacement.type === 'simple'" class="block">
<div class="mb-2">{{ replacement.description }}</div>
<div v-if="replacement.example">
<strong class="block mb-1.5">{{ $t('package.replacement.example') }}</strong>
<pre
class="bg-amber-800/10 dark:bg-amber-950/30 p-2 rounded border border-amber-700/20 overflow-x-auto text-xs font-mono leading-relaxed"
><code>{{ replacement.example }}</code></pre>
</div>
</div>
<i18n-t
v-else-if="replacement.type === 'documented'"
keypath="package.replacement.documented"
scope="global"
>
<template #replacement>
<code>{{ replacement.replacementModule }}</code>
</template>
<template #community>
<a
href="https://e18e.dev/docs/replacements/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 ms-1 underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg transition-colors"
>
{{ $t('package.replacement.community') }}
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
</template>
<a
v-if="mdnUrl"
:href="mdnUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 ms-1 underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg transition-colors"
>
{{ $t('package.replacement.mdn') }}
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
<a
v-if="docPath"
:href="docPath"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 ms-1 underline underline-offset-4 decoration-amber-600/60 dark:decoration-amber-400/50 hover:decoration-fg transition-colors"
>
{{ $t('package.replacement.learn_more') }}
<span class="i-lucide:external-link w-3 h-3" aria-hidden="true" />
</a>
</p>
</i18n-t>
<template v-else-if="replacement.type === 'removal'">
{{ replacement.description }}
</template>
<template v-else>
{{ $t('package.replacement.none') }}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@coderabbitai How do I make replacement.description render with markdown and what e2e test should I add?

Copy link
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for markdown rendering libraries in package.json
cat package.json | grep -i "mark\|prose\|remark\|mdx\|shiki\|highlight"

echo "---"
# Check for existing markdown rendering in Vue components
rg -n "v-html\|marked\|markdown\|prose\|remark\|mdx" --type=vue -l

echo "---"
# Look at how markdown is rendered in any existing component
rg -n "v-html\|marked\|markdown" --type=vue -A2 -B2 | head -80

echo "---"
# Check e2e test setup
fd -e spec.ts -e test.ts | head -30

echo "---"
# Check for playwright or cypress config
fd "playwright\|cypress\|e2e" --type f | head -20

echo "---"
# Check nuxt test utils or playwright usage
rg -n "test\|describe\|it\(" test/nuxt/a11y.spec.ts | head -30

echo "---"
# Check the a11y spec for replacement-related tests
grep -n "replacement\|Replacement" test/nuxt/a11y.spec.ts | head -30

Length of output: 3025


🏁 Script executed:

#!/bin/bash
# Find how marked is used in the project
rg -n "marked\|useMarkdown\|renderMarkdown" --type=ts -l
echo "---"
rg -n "marked\|useMarkdown\|renderMarkdown" -g "*.vue" -l
echo "---"
# Look at existing markdown usage
rg -n "marked\|v-html" -g "*.ts" -A3 -B1 | head -60
echo "---"
# Check composables for markdown
fd -e ts composables | xargs grep -l "marked\|markdown" 2>/dev/null | head -10
echo "---"
# Look at existing e2e package-replacements spec
cat test/e2e/package-replacements.spec.ts
echo "---"
# Look at a11y spec for PackageReplacement (around line 2518)
sed -n '2518,2620p' test/nuxt/a11y.spec.ts

Length of output: 4012


🏁 Script executed:

#!/bin/bash
# Search for marked usage across all files
rg -n "from 'marked'\|import marked\|useMarked" -A2 | head -50
echo "---"
# List utils files
fd . app/utils --type f | head -30
echo "---"
# Check if there's a markdown composable
fd . app/composables --type f | xargs grep -l "marked\|markdown\|html" 2>/dev/null | head -10
echo "---"
# Check the resolve-replacement-url util
cat app/utils/resolve-replacement-url.ts
echo "---"
# Check how v-html is used in the project (broader search)
rg -n "v-html" -g "*.vue" | head -30
echo "---"
# Check how the Readme component renders HTML (it uses html prop in a11y test)
fd "Readme" app/components --type f | head -5

Length of output: 3240


🏁 Script executed:

#!/bin/bash
# Examine the useMarkdown composable
cat app/composables/useMarkdown.ts
echo "---"
# Check how Card.vue uses pkgDescription (likely markdown)
sed -n '1,100p' app/components/Package/Card.vue
echo "---"
# Check if there's DOMPurify or sanitization
rg -n "DOMPurify\|sanitize\|xss" -g "*.ts" | head -20
rg -n "DOMPurify\|sanitize\|xss" -g "*.vue" | head -20

Length of output: 7060


@gameroman, here's how to approach both questions.


1. Rendering replacement.description as Markdown

The project already has a useMarkdown composable (app/composables/useMarkdown.ts) that handles inline markdown (bold, italic, inline code, links, strikethrough) with built-in XSS sanitization via stripAndEscapeHtml. This is the right tool to use.

Script changes

Add one computed ref in the <script setup>:

import { resolveReplacementUrl } from '~/utils/resolve-replacement-url'
// ^ already present

const descriptionHtml = useMarkdown(() => ({
  text: props.replacement.description ?? '',
}))

Template changes

simple type — replace the plain <div> with v-html:

- <div class="mb-2">{{ replacement.description }}</div>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <div class="mb-2" v-html="descriptionHtml" />

removal type — same pattern:

 <template v-else-if="replacement.type === 'removal'">
-  {{ replacement.description }}
+  <!-- eslint-disable-next-line vue/no-v-html -->
+  <span v-html="descriptionHtml" />
 </template>

native type — the description there is used as a code identifier label inside <code>, so markdown rendering is not meaningful. Leave it as plain text interpolation as-is.

Note: The useMarkdown composable strips all raw HTML tags first, then applies its own safe inline-markdown transformations, so using v-html on its output is safe here — consistent with the pattern in Card.vue (pkgDescription).


2. E2e Tests to Add

test/e2e/package-replacements.spec.ts

Add a test for a simple replacement (description rendered as HTML) and a native replacement (node version shown):

test('/package/is-even shows a simple replacement with a description', async ({
  page,
  goto,
}) => {
  await goto('/package/is-even', { waitUntil: 'hydration' })
  await expect(page.locator('h1')).toContainText('is-even', { timeout: 15_000 })

  // The replacement section should render the plain-text/markdown description
  const replacementSection = page.locator('[data-testid="replacement"]') // or adjust selector to match your component
  await expect(replacementSection).toBeVisible({ timeout: 15_000 })
  await expect(replacementSection).toContainText('modulo')
})

test('/package/array-every shows a native replacement with node version', async ({
  page,
  goto,
}) => {
  await goto('/package/array-every', { waitUntil: 'hydration' })
  await expect(page.locator('h1')).toContainText('array-every', { timeout: 15_000 })

  // Native replacement with nodeVersion should render the i18n "native" message
  const replacementSection = page.locator('.bg-amber-500\\/10')
  await expect(replacementSection).toBeVisible({ timeout: 15_000 })
  await expect(replacementSection).toContainText('0.10.0')
})

Adjust the selectors to match your actual DOM — you may want to add a data-testid="replacement-card" attribute to the root <div> in Replacement.vue to make selection robust.

test/nuxt/a11y.spec.ts

Add a removal type case to the existing PackageReplacement describe block (after the existing simple test):

it('should have no accessibility violations for removal replacement', async () => {
  const component = await mountSuspended(PackageReplacement, {
    props: {
      mapping: {
        type: 'module',
        moduleName: 'some-deprecated-pkg',
        replacements: ['snippet::removal-example'],
      },
      replacement: {
        id: 'snippet::removal-example',
        type: 'removal',
        description: 'This package is no longer needed. Use **native** APIs instead.',
      },
    },
  })
  const results = await runAxe(component)
  expect(results.violations).toEqual([])
})

This also doubles as a smoke test that markdown in description (the **native**) renders without breaking accessibility.


🧠 Learnings used
Learnt from: alexdln
Repo: npmx-dev/npmx.dev PR: 838
File: app/pages/package/[...package].vue:445-449
Timestamp: 2026-02-03T13:59:33.392Z
Learning: The copy button pattern in app/pages/package/[...package].vue may be made into a reusable component or pattern in the future, but currently it's acceptable to keep it inline with the CSS-only approach for smooth animations.

Learnt from: jellydeck
Repo: npmx-dev/npmx.dev PR: 904
File: app/components/Package/AccessControls.vue:253-253
Timestamp: 2026-02-04T05:34:20.527Z
Learning: In the npmx.dev project, ensure that focus-visible styling for button and select elements is implemented globally in app/assets/main.css with the rule: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Do not apply per-element inline utility classes like focus-visible:outline-accent/70 on these elements in Vue templates or components (e.g., AccessControls.vue). Rely on the global rule for consistency and maintainability; only use inline focus-visible utilities when styling non-button/select elements or in exceptional cases outside the global scope.

Learnt from: jellydeck
Repo: npmx-dev/npmx.dev PR: 904
File: app/components/Package/Versions.vue:332-332
Timestamp: 2026-02-04T05:34:54.335Z
Learning: In the npmx.dev project, focus-visible styling for buttons and selects is applied globally via main.css with: button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }. Therefore, individual buttons or selects in Vue components should not rely on inline focus-visible utility classes like focus-visible:outline-accent/70. Ensure components do not add per-element focus-visible utilities; rely on the global rule. If a component seems to require a different focus state, adjust only through global CSS or maintain accessibility with native focus styles, rather than inserting inline utility classes.

Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 1170
File: app/components/Package/MetricsBadges.vue:66-66
Timestamp: 2026-02-08T15:02:02.232Z
Learning: In Vue components that use UnoCSS with the preset-icons collection, prefer colon-syntax for icons (e.g., i-carbon:checkmark) over the dash-separated form (i-carbon-checkmark). This aids UnoCSS in resolving the correct collection directly, which improves performance for long icon names. Apply this pattern to all Vue components (e.g., app/components/**/*.vue) where UnoCSS icons are used; ensure UnoCSS is configured with the preset-icons collection.

Learnt from: userquin
Repo: npmx-dev/npmx.dev PR: 1335
File: app/components/Compare/FacetSelector.vue:72-78
Timestamp: 2026-02-10T15:47:33.467Z
Learning: In the npmx.dev project, ButtonBase (used via app/components/ButtonBase.vue or similar) provides default classes: border border-border. When styling ButtonBase instances in Vue components (e.g., app/components/Compare/FacetSelector.vue and other files under app/components), avoid duplicating the border class to prevent the HTML validator error no-dup-class and CI failures. If styling overrides are needed, ensure only unique classes are applied (remove redundant border classes or adjust via props) so the default border remains intact without duplication.

Learnt from: abbeyperini
Repo: npmx-dev/npmx.dev PR: 1049
File: app/components/Settings/Toggle.client.vue:22-29
Timestamp: 2026-02-11T00:01:33.121Z
Learning: In Vue 3.4 and later, you can use same-name shorthand for attribute bindings: use :attributeName instead of :attributeName="attributeName" when binding to a variable with the same name in scope. For example, :id is equivalent to :id="id" when an id variable exists. Apply this shorthand in .vue components (notably in Settings/Toggle.client.vue) to simplify templates. Ensure the bound variable exists and that you are using a Vue version that supports this shorthand.

Learnt from: alexdln
Repo: npmx-dev/npmx.dev PR: 1845
File: app/components/InstantSearch.vue:6-11
Timestamp: 2026-03-03T09:42:52.533Z
Learning: Maintain the established prehydration pattern across the project: use JSON.parse(localStorage.getItem('npmx-settings') || '{}') without per-call try-catch blocks. Do not introduce try-catch error handling for this pattern unless a coordinated, project-wide refactor of all onPrehydrate readers is planned and executed.

Learnt from: graphieros
Repo: npmx-dev/npmx.dev PR: 1974
File: app/components/Compare/FacetBarChart.vue:174-178
Timestamp: 2026-03-07T12:00:18.120Z
Learning: When reviewing Compare/FacetBarChart.vue (or other Vue components in app/components), treat config.datapoint as a runtime object that can include extra properties from the dataset item (e.g., formattedValue, custom metadata). The TypeScript typings for config.datapoint may be incomplete, but that should not be flagged as a runtime error. Base reviews on actual runtime behavior and data presence, not on missing type checks; focus on correct usage and data-driven effects.

Learnt from: mihaizaurus
Repo: npmx-dev/npmx.dev PR: 2072
File: app/components/Compare/ComparisonGrid.vue:38-40
Timestamp: 2026-03-14T08:57:22.084Z
Learning: In Vue components, when using v-for, provide a stable unique key (e.g., :key="col.name"). This learning notes that in app/components/Compare/ComparisonGrid.vue, using :key="col.name" is safe because duplicates are prevented upstream: app/components/Compare/PackageSelector.vue enforces two guards (filteredResults excludes already-selected names; addPackage early-returns if the name already exists). Do not flag :key="col.name" as a collision risk in this context. If applying this guideline elsewhere, ensure a unique key is guaranteed by logic or consider fallbacks (e.g., index or composite keys) only if duplicates could be introduced.

Learnt from: ShroXd
Repo: npmx-dev/npmx.dev PR: 2025
File: app/components/Package/Versions.vue:533-540
Timestamp: 2026-03-15T03:59:32.483Z
Learning: In Vue 3, templates that use v-if/v-else render exactly one root element at runtime. Attributes (data-testid, title, etc.) will fall through to the active root without needing explicit prop forwarding. Do not flag attribute forwarding as a bug for this pattern; reserve review for cases where multiple roots render simultaneously or where explicit forwarding is required by design. This applies broadly to Vue single-file components using v-if/v-else.

@gameroman
Copy link
Contributor Author

Waiting on es-tooling/module-replacements#481

@gameroman
Copy link
Contributor Author

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants