From b1cd3803ace8058d9488b914b2634880de1e753b Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 14:33:27 +0100 Subject: [PATCH 01/16] chore: add missing compare page at `routeRules` (prerender) --- nuxt.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/nuxt.config.ts b/nuxt.config.ts index 39d4bbb4f..91c7ee927 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -138,6 +138,7 @@ export default defineNuxtConfig({ '/': { prerender: true }, '/200.html': { prerender: true }, '/about': { prerender: true }, + '/compare': { prerender: true }, '/privacy': { prerender: true }, '/search': { isr: false, cache: false }, // never cache '/settings': { prerender: true }, From 58d47967a1dcf2144bde411d5d65630aa05b2d79 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 16:01:15 +0100 Subject: [PATCH 02/16] chore: patch nuxt html validator module --- nuxt.config.ts | 4 ++++ pnpm-lock.yaml | 7 +++++-- pnpm-workspace.yaml | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index 91c7ee927..08a9da54d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,6 +2,8 @@ import process from 'node:process' import { currentLocales } from './config/i18n' import { isCI, provider } from 'std-env' +console.log(!isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML)) + export default defineNuxtConfig({ modules: [ '@unocss/nuxt', @@ -220,6 +222,8 @@ export default defineNuxtConfig({ htmlValidator: { enabled: !isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML), + ignore: + !isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML) ? ['/compare'] : undefined, failOnError: true, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21602824a..825fe3aea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ patchedDependencies: '@jsr/deno__doc@0.189.1': hash: 24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832 path: patches/@jsr__deno__doc@0.189.1.patch + '@nuxtjs/html-validator@2.1.0': + hash: a9cd3bd21a40b9a66b64a887d62ae4bc754d4af3ceeed3897df20afcbca84882 + path: patches/@nuxtjs__html-validator@2.1.0.patch importers: @@ -73,7 +76,7 @@ importers: version: 4.0.0(magicast@0.5.1) '@nuxtjs/html-validator': specifier: 2.1.0 - version: 2.1.0(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1) + version: 2.1.0(patch_hash=a9cd3bd21a40b9a66b64a887d62ae4bc754d4af3ceeed3897df20afcbca84882)(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1) '@nuxtjs/i18n': specifier: 10.2.3 version: 10.2.3(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(@vue/compiler-dom@3.5.27)(db0@0.3.4(better-sqlite3@12.6.2))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(rollup@4.57.0)(vue@3.5.27(typescript@5.9.3)) @@ -12372,7 +12375,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/html-validator@2.1.0(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1)': + '@nuxtjs/html-validator@2.1.0(patch_hash=a9cd3bd21a40b9a66b64a887d62ae4bc754d4af3ceeed3897df20afcbca84882)(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1)': dependencies: '@nuxt/kit': 3.21.0(magicast@0.5.1) consola: 3.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5f8834531..911265e24 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,6 +32,7 @@ packageExtensions: patchedDependencies: '@jsr/deno__doc@0.189.1': patches/@jsr__deno__doc@0.189.1.patch + '@nuxtjs/html-validator@2.1.0': patches/@nuxtjs__html-validator@2.1.0.patch savePrefix: '' From 7df6416aff82f6bdb3c76ea12786460dfa5c156a Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 16:05:31 +0100 Subject: [PATCH 03/16] chore: add nuxt html validator patch --- patches/@nuxtjs__html-validator@2.1.0.patch | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 patches/@nuxtjs__html-validator@2.1.0.patch diff --git a/patches/@nuxtjs__html-validator@2.1.0.patch b/patches/@nuxtjs__html-validator@2.1.0.patch new file mode 100644 index 000000000..ce526314f --- /dev/null +++ b/patches/@nuxtjs__html-validator@2.1.0.patch @@ -0,0 +1,22 @@ +diff --git a/dist/module.mjs b/dist/module.mjs +index 62bb9c885e18652f48f6c067258f3ad70582b1f5..eb62642ba197cb7070708bdd01d733a790fb7d6f 100644 +--- a/dist/module.mjs ++++ b/dist/module.mjs +@@ -82,7 +82,7 @@ const module = defineNuxtModule({ + } + if (!nuxt.options.dev) { + const validatorPath = await resolvePath(fileURLToPath(new URL("./runtime/validator", import.meta.url))); +- const { useChecker, getValidator } = await (isWindows ? import(pathToFileURL(validatorPath).href) : import(validatorPath)); ++ const { useChecker, getValidator, isIgnored } = await (isWindows ? import(pathToFileURL(validatorPath).href) : import(validatorPath)); + const validator = getValidator(options); + const { checkHTML, invalidPages } = useChecker(validator, usePrettier, logLevel); + if (failOnError) { +@@ -98,7 +98,7 @@ const module = defineNuxtModule({ + if (!route.contents || !route.fileName?.endsWith(".html")) { + return; + } +- if (route.contents.match(NuxtRedirectHtmlRegex)) { ++ if (route.contents.match(NuxtRedirectHtmlRegex) || isIgnored(route.route, moduleOptions.ignore)) { + return; + } + checkHTML(route.route, route.contents); From 746aa0cf521638b0813dd5900a6be976a26394bd Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 16:06:36 +0100 Subject: [PATCH 04/16] chore: cleanup nuxt config file --- nuxt.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index 08a9da54d..c2bea1c32 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,8 +2,6 @@ import process from 'node:process' import { currentLocales } from './config/i18n' import { isCI, provider } from 'std-env' -console.log(!isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML)) - export default defineNuxtConfig({ modules: [ '@unocss/nuxt', From d68c1da4c5ae9f72b483991bb9d2ecc753492edb Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 16:24:04 +0100 Subject: [PATCH 05/16] chore: remove patch --- patches/@nuxtjs__html-validator@2.1.0.patch | 22 --------------------- pnpm-lock.yaml | 7 ++----- pnpm-workspace.yaml | 1 - 3 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 patches/@nuxtjs__html-validator@2.1.0.patch diff --git a/patches/@nuxtjs__html-validator@2.1.0.patch b/patches/@nuxtjs__html-validator@2.1.0.patch deleted file mode 100644 index ce526314f..000000000 --- a/patches/@nuxtjs__html-validator@2.1.0.patch +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/dist/module.mjs b/dist/module.mjs -index 62bb9c885e18652f48f6c067258f3ad70582b1f5..eb62642ba197cb7070708bdd01d733a790fb7d6f 100644 ---- a/dist/module.mjs -+++ b/dist/module.mjs -@@ -82,7 +82,7 @@ const module = defineNuxtModule({ - } - if (!nuxt.options.dev) { - const validatorPath = await resolvePath(fileURLToPath(new URL("./runtime/validator", import.meta.url))); -- const { useChecker, getValidator } = await (isWindows ? import(pathToFileURL(validatorPath).href) : import(validatorPath)); -+ const { useChecker, getValidator, isIgnored } = await (isWindows ? import(pathToFileURL(validatorPath).href) : import(validatorPath)); - const validator = getValidator(options); - const { checkHTML, invalidPages } = useChecker(validator, usePrettier, logLevel); - if (failOnError) { -@@ -98,7 +98,7 @@ const module = defineNuxtModule({ - if (!route.contents || !route.fileName?.endsWith(".html")) { - return; - } -- if (route.contents.match(NuxtRedirectHtmlRegex)) { -+ if (route.contents.match(NuxtRedirectHtmlRegex) || isIgnored(route.route, moduleOptions.ignore)) { - return; - } - checkHTML(route.route, route.contents); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 825fe3aea..21602824a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,9 +15,6 @@ patchedDependencies: '@jsr/deno__doc@0.189.1': hash: 24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832 path: patches/@jsr__deno__doc@0.189.1.patch - '@nuxtjs/html-validator@2.1.0': - hash: a9cd3bd21a40b9a66b64a887d62ae4bc754d4af3ceeed3897df20afcbca84882 - path: patches/@nuxtjs__html-validator@2.1.0.patch importers: @@ -76,7 +73,7 @@ importers: version: 4.0.0(magicast@0.5.1) '@nuxtjs/html-validator': specifier: 2.1.0 - version: 2.1.0(patch_hash=a9cd3bd21a40b9a66b64a887d62ae4bc754d4af3ceeed3897df20afcbca84882)(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1) + version: 2.1.0(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1) '@nuxtjs/i18n': specifier: 10.2.3 version: 10.2.3(@upstash/redis@1.36.1)(@vercel/kv@3.0.0)(@vue/compiler-dom@3.5.27)(db0@0.3.4(better-sqlite3@12.6.2))(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(magicast@0.5.1)(rollup@4.57.0)(vue@3.5.27(typescript@5.9.3)) @@ -12375,7 +12372,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxtjs/html-validator@2.1.0(patch_hash=a9cd3bd21a40b9a66b64a887d62ae4bc754d4af3ceeed3897df20afcbca84882)(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1)': + '@nuxtjs/html-validator@2.1.0(@voidzero-dev/vite-plus-test@0.0.0-833c515fa25cef20905a7f9affb156dfa6f151ab(@types/node@24.10.9)(esbuild@0.27.3)(happy-dom@20.4.0)(jiti@2.6.1)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))(magicast@0.5.1)': dependencies: '@nuxt/kit': 3.21.0(magicast@0.5.1) consola: 3.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 911265e24..5f8834531 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,7 +32,6 @@ packageExtensions: patchedDependencies: '@jsr/deno__doc@0.189.1': patches/@jsr__deno__doc@0.189.1.patch - '@nuxtjs/html-validator@2.1.0': patches/@nuxtjs__html-validator@2.1.0.patch savePrefix: '' From c4d9a4caeb2e9175d1ef8422c608ffc895e2c229 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 16:34:58 +0100 Subject: [PATCH 06/16] chore: cleanup nuxt config file, remove ignore option --- nuxt.config.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index c2bea1c32..91c7ee927 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -220,8 +220,6 @@ export default defineNuxtConfig({ htmlValidator: { enabled: !isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML), - ignore: - !isCI || (provider !== 'vercel' && !!process.env.VALIDATE_HTML) ? ['/compare'] : undefined, failOnError: true, }, From 87e3b6deeae808ecf446fc752960beab56e08de6 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 16:37:47 +0100 Subject: [PATCH 07/16] chore: cleanup duplicated classes at CompareFacetSelector --- app/components/Compare/FacetSelector.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Compare/FacetSelector.vue b/app/components/Compare/FacetSelector.vue index e3fb55b95..c1becca79 100644 --- a/app/components/Compare/FacetSelector.vue +++ b/app/components/Compare/FacetSelector.vue @@ -69,12 +69,12 @@ function isCategoryNoneSelected(category: string): boolean { :disabled="facet.comingSoon" :aria-pressed="isFacetSelected(facet.id)" :aria-label="facet.label" - class="inline-flex items-center gap-1 px-1.5 py-0.5 font-mono text-xs rounded border transition-colors duration-200 focus-visible:outline-accent/70" + class="gap-1 px-1.5 rounded" :class=" facet.comingSoon ? 'text-fg-subtle/50 bg-bg-subtle border-border-subtle cursor-not-allowed' : isFacetSelected(facet.id) - ? 'text-fg-muted bg-bg-muted border-border' + ? 'text-fg-muted bg-bg-muted' : 'text-fg-subtle bg-bg-subtle border-border-subtle hover:text-fg-muted hover:border-border' " @click="!facet.comingSoon && toggleFacet(facet.id)" From 3e46cc8b8931cb5afffabec8dc3cfd1c5ed6bd84 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 20:38:14 +0100 Subject: [PATCH 08/16] chore: add client only and restore facets from url --- app/components/Compare/FacetRow.vue | 4 +- app/composables/usePackageComparison.ts | 30 ++- app/pages/compare.vue | 263 ++++++++++++++---------- 3 files changed, 181 insertions(+), 116 deletions(-) diff --git a/app/components/Compare/FacetRow.vue b/app/components/Compare/FacetRow.vue index 6c0b49a5b..f8b1554e4 100644 --- a/app/components/Compare/FacetRow.vue +++ b/app/components/Compare/FacetRow.vue @@ -120,9 +120,9 @@ function isCellLoading(index: number): boolean { /> - + diff --git a/app/composables/usePackageComparison.ts b/app/composables/usePackageComparison.ts index aa03fbae3..411808e30 100644 --- a/app/composables/usePackageComparison.ts +++ b/app/composables/usePackageComparison.ts @@ -75,11 +75,15 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { const bytesFormatter = useBytesFormatter() const packages = computed(() => toValue(packageNames)) + const ready = shallowRef(false) + // Cache of fetched data by package name (source of truth) const cache = shallowRef(new Map()) // Derived array in current package order - const packagesData = computed(() => packages.value.map(name => cache.value.get(name) ?? null)) + const packagesData = computed( + () => ready.value && packages.value.map(name => cache.value.get(name) ?? null), + ) const status = shallowRef<'idle' | 'pending' | 'success' | 'error'>('idle') const error = shallowRef(null) @@ -249,18 +253,24 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Watch for package changes and refetch (client-side only) if (import.meta.client) { - watch( - packages, - newPackages => { - fetchPackages(newPackages) - }, - { immediate: true }, - ) + useNuxtApp().hook('app:suspense:resolve', () => { + ready.value = true + watch( + packages, + newPackages => { + fetchPackages(newPackages) + }, + { immediate: true }, + ) + }) } // Compute values for each facet function getFacetValues(facet: ComparisonFacet): (FacetValue | null)[] { - if (!packagesData.value || packagesData.value.length === 0) return [] + // If not ready or no data, return array of nulls to render skeletons + if (!ready.value || !packagesData.value || packagesData.value.length === 0) { + return Array.from({ length: packages.value.length }, () => null) + } return packagesData.value.map(pkg => { if (!pkg) return null @@ -277,7 +287,7 @@ export function usePackageComparison(packageNames: MaybeRefOrGetter) { // Check if a facet depends on slow-loading data function isFacetLoading(facet: ComparisonFacet): boolean { - if (!installSizeLoading.value) return false + if (!ready.value || !installSizeLoading.value) return false // These facets depend on install-size API return facet === 'installSize' || facet === 'totalDependencies' } diff --git a/app/pages/compare.vue b/app/pages/compare.vue index a3c9a7c8d..73a97fbe1 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -47,7 +47,11 @@ const gridColumns = computed(() => .map((pkg, i) => ({ pkg, originalIndex: i })) .filter(({ pkg }) => pkg !== NO_DEPENDENCY_ID) .map(({ pkg, originalIndex }) => { - const data = packagesData.value?.[originalIndex] + // packagesData can be false (not ready) or array with nulls (loading) + // Ensure we handle the boolean case safely for TS + const list = packagesData.value + const data = Array.isArray(list) ? list[originalIndex] : null + return { name: data?.package.name || pkg, version: data?.package.version, @@ -80,10 +84,12 @@ const gridHeaders = computed(() => ) useSeoMeta({ - title: () => - packages.value.length > 0 - ? $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) - : $t('compare.packages.meta_title_empty'), + title: () => { + if (packages.value.length === 0) return $t('compare.packages.meta_title_empty') + const title = $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) + // Avoid long titles (SEO/HTML validation) + return title.length > 60 ? $t('compare.packages.meta_title_empty') : title + }, ogTitle: () => packages.value.length > 0 ? $t('compare.packages.meta_title', { packages: packages.value.join(' vs ') }) @@ -135,31 +141,38 @@ useSeoMeta({

{{ $t('compare.packages.section_packages') }}

- + + + + - -
- -
+ + +
+ +
- -
- -
+ +
+ +
+
@@ -168,47 +181,86 @@ useSeoMeta({

{{ $t('compare.packages.section_facets') }}

- - {{ $t('compare.facets.all') }} - - - - {{ $t('compare.facets.none') }} - + +
+ + {{ $t('compare.facets.all') }} + + + + {{ $t('compare.facets.none') }} + +
+ +
- + + + + - -
-

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

+ + +
+

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

-
- -
+ +
+ + -
- - - -
- + +
-

- {{ $t('compare.facets.trends.title') }} + +
+
- - - - -

- - -
-
+ + From 831437039a23542618cbd0465f7114a7e9d59e70 Mon Sep 17 00:00:00 2001 From: userquin Date: Tue, 10 Feb 2026 21:18:48 +0100 Subject: [PATCH 09/16] chore: add `JSRequired` component --- app/components/JSRequired.vue | 10 ++++++++++ app/pages/compare.vue | 5 ++++- i18n/locales/en.json | 1 + i18n/schema.json | 3 +++ lunaria/files/en-GB.json | 1 + lunaria/files/en-US.json | 1 + 6 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 app/components/JSRequired.vue diff --git a/app/components/JSRequired.vue b/app/components/JSRequired.vue new file mode 100644 index 000000000..0e7dce4bf --- /dev/null +++ b/app/components/JSRequired.vue @@ -0,0 +1,10 @@ + diff --git a/app/pages/compare.vue b/app/pages/compare.vue index 73a97fbe1..367db5907 100644 --- a/app/pages/compare.vue +++ b/app/pages/compare.vue @@ -136,6 +136,8 @@ useSeoMeta({

+ +

@@ -304,7 +306,8 @@ useSeoMeta({