From efb48e06972555d909e6bbf8639a2c72269d4c82 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 13 Oct 2026 16:28:05 +0200 Subject: [PATCH 1/2] fix(deps): patch nitropack so comark plugins stay external on Nuxt 4.6 --- .github/workflows/ci.yml | 7 +- package.json | 6 +- patches/nitropack@2.13.4.patch | 133 +++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 27 ++++--- pnpm-workspace.yaml | 8 +- test/e2e/bundle.test.ts | 50 +++++++++++++ test/e2e/routes.test.ts | 69 +++++++++++++++++ test/e2e/setup.ts | 67 +++++++++++++++++ vitest.config.ts | 25 ++++++- 9 files changed, 373 insertions(+), 19 deletions(-) create mode 100644 patches/nitropack@2.13.4.patch create mode 100644 test/e2e/bundle.test.ts create mode 100644 test/e2e/routes.test.ts create mode 100644 test/e2e/setup.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6c5542..bcd3dba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,12 @@ jobs: - run: pnpm test - run: pnpm dev:prepare - run: pnpm typecheck - # Builds the playground, which exercises the layer end to end: content - # pages, landing, OG images, and the generated ISR route rules. + # Builds the playground: module setup, bundling, and the generated ISR route rules. - run: pnpm build + - run: pnpm test:e2e + env: + GITHUB_TOKEN: ${{ github.token }} + NUXT_DOCS_GITHUB_BRANCH: ${{ github.event.pull_request.head.sha || github.sha }} # Continuous preview releases via pkg.pr.new: every commit gets an installable # tarball (https://pkg.pr.new/comarkdown/comark-docs@), so downstream sites # can test the layer before a release. Requires the pkg.pr.new GitHub App. diff --git a/package.json b/package.json index a4d3522..4c38719 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,9 @@ "typecheck": "nuxt typecheck playground", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "vitest run", - "test:watch": "vitest", + "test": "vitest run --project unit", + "test:e2e": "vitest run --project e2e", + "test:watch": "vitest --project unit", "dev:prepare": "nuxt prepare playground" }, "dependencies": { @@ -95,6 +96,7 @@ "@opentelemetry/resources": "^2.11.0", "@opentelemetry/sdk-trace-base": "^2.11.0", "@opentelemetry/sdk-trace-node": "^2.11.0", + "es-module-lexer": "^3.0.2", "eslint": "^10.10.0", "nuxt": "^4.6.0", "typescript": "^6.0.3", diff --git a/patches/nitropack@2.13.4.patch b/patches/nitropack@2.13.4.patch new file mode 100644 index 0000000..be1eac4 --- /dev/null +++ b/patches/nitropack@2.13.4.patch @@ -0,0 +1,133 @@ +nitropack@2.13.4, two fixes until a Nitro v2 release ships them: + +1. dist/presets/vercel/utils.mjs: nitrojs/nitro#4651, header-only Vercel routes need `continue: true`. +2. dist/rollup/index.mjs: externals map files back to pattern exports (`comark/plugins/*`). + Without it, layer components importing a comark plugin crash SSR on Nuxt 4.6. + Not upstream yet. + +Each patched block is tagged with a `[comark-docs patch]` comment. + +diff --git a/dist/presets/vercel/utils.mjs b/dist/presets/vercel/utils.mjs +index 6b4a25e30eceded9b820a446c3c4519f4f43c0ac..8a807ea3d67b754e2bc409914105cf97fb7ff5d9 100644 +--- a/dist/presets/vercel/utils.mjs ++++ b/dist/presets/vercel/utils.mjs +@@ -129,19 +129,24 @@ function generateBuildConfig(nitro, o11Routes) { + ) + }, + routes: [ +- // Redirect and header rules +- ...rules.filter(([_, routeRules]) => routeRules.redirect || routeRules.headers).map(([path, routeRules]) => { ++ // [comark-docs patch] nitrojs/nitro#4651, merged in Nitro v2 but not released yet. ++ // Header-only rules (least specific first, so more specific headers override on `continue`) ++ ...rules.filter(([_, routeRules]) => routeRules.headers && !routeRules.redirect).reverse().map(([path, routeRules]) => ({ ++ src: path.replace("/**", "/(.*)"), ++ headers: routeRules.headers, ++ continue: true ++ })), ++ // Redirect rules ++ ...rules.filter( ++ (entry) => !!entry[1].redirect ++ ).map(([path, routeRules]) => { + let route = { +- src: path.replace("/**", "/(.*)") ++ src: path.replace("/**", "/(.*)"), ++ status: routeRules.redirect.statusCode, ++ headers: { ++ Location: routeRules.redirect.to.replace("/**", "/$1") ++ } + }; +- if (routeRules.redirect) { +- route = defu(route, { +- status: routeRules.redirect.statusCode, +- headers: { +- Location: routeRules.redirect.to.replace("/**", "/$1") +- } +- }); +- } + if (routeRules.headers) { + route = defu(route, { headers: routeRules.headers }); + } +diff --git a/dist/rollup/index.mjs b/dist/rollup/index.mjs +index 9aadcbc7925e965062670f1d86436f8e0830b200..3d55aefa5647fe6f313aa86abf3ae2e34c3aebab 100644 +--- a/dist/rollup/index.mjs ++++ b/dist/rollup/index.mjs +@@ -413,10 +413,19 @@ function externals$1(opts) { + } + const packageEntry = tryResolve(pkgName, importer); + if (packageEntry !== id) { +- const guessedSubpath = await lookupNodeModuleSubpath(id).catch(() => null); +- const resolvedGuess = guessedSubpath && tryResolve(join(pkgName, guessedSubpath), importer); +- if (resolvedGuess === id) { +- trackedExternals.add(resolvedGuess); ++ // [comark-docs patch] Map files back to pattern exports (`comark/plugins/*`), not upstream yet. ++ const resolvesToId = (subpath) => !!subpath && tryResolve(join(pkgName, subpath), importer) === id; ++ const exactSubpath = await lookupNodeModuleSubpath(id).catch( ++ () => null ++ ); ++ const guessedSubpath = resolvesToId(exactSubpath) ? exactSubpath : ( ++ // `lookupNodeModuleSubpath` only matches exact export targets ++ (await lookupPatternExportSubpaths(id)).find( ++ (subpath) => resolvesToId(subpath) ++ ) ++ ); ++ if (guessedSubpath) { ++ trackedExternals.add(id); + return { + id: join(pkgName, guessedSubpath), + external: true +@@ -686,6 +695,55 @@ function applyProductionCondition(exports$1) { + applyProductionCondition(exports$1[key]); + } + } ++// [comark-docs patch] Pattern-export lookup used by the externals plugin above. ++async function lookupPatternExportSubpaths(id) { ++ const { dir, name, subpath } = parseNodeModulePath$1(id); ++ if (!dir || !name || !subpath) { ++ return []; ++ } ++ const { exports } = await readPackageJSON(join(dir, name)).catch( ++ () => ({}) ++ ); ++ return findPatternExportSubpaths(exports, subpath); ++} ++function findPatternExportSubpaths(exports, subpath) { ++ if (!exports || typeof exports !== "object" || Array.isArray(exports)) { ++ return []; ++ } ++ const subpaths = []; ++ for (const [key, value] of Object.entries(exports)) { ++ if (!key.startsWith("./") || key.split("*").length !== 2) { ++ continue; ++ } ++ for (const target of exportTargets(value)) { ++ const parts = target.split("*"); ++ if (parts.length !== 2) { ++ continue; ++ } ++ const [prefix, suffix] = parts; ++ if (subpath.length > prefix.length + suffix.length && subpath.startsWith(prefix) && subpath.endsWith(suffix)) { ++ const match = subpath.slice( ++ prefix.length, ++ subpath.length - suffix.length ++ ); ++ subpaths.push(key.replace("*", match)); ++ } ++ } ++ } ++ return subpaths; ++} ++function exportTargets(value) { ++ if (typeof value === "string") { ++ return [value]; ++ } ++ if (Array.isArray(value)) { ++ return value.flatMap((item) => exportTargets(item)); ++ } ++ if (value && typeof value === "object") { ++ return Object.values(value).flatMap((item) => exportTargets(item)); ++ } ++ return []; ++} + async function isFile$1(file) { + try { + const stat = await promises.stat(file); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d1bcc4..782f9e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,7 +164,9 @@ settings: overrides: h3: ^1.15.11 - nitropack: https://pkg.pr.new/nitrojs/nitro/nitropack@4651 + +patchedDependencies: + nitropack@2.13.4: 20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b importers: @@ -274,7 +276,7 @@ importers: version: https://pkg.pr.new/nuxt-content/nuxt-llms/nuxt-llms@f6a9730(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))) nuxt-og-image: specifier: ^6.8.0 - version: 6.9.0(42f5d805aec283ad50adcd34e4cf6320) + version: 6.9.0(bd4377c344c0350ed72e6c8bb890f801) nuxt-schema-org: specifier: ^6.3.2 version: 6.3.2(79312463b356f834dede6b5b5a2687ca) @@ -308,7 +310,7 @@ importers: devDependencies: '@nuxt/devtools-kit': specifier: 4.0.0-alpha.20 - version: 4.0.0-alpha.20(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))))(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + version: 4.0.0-alpha.20(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))))(nitropack@2.13.4(patch_hash=20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b)(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) '@nuxt/eslint-config': specifier: ^1.17.0 version: 1.17.0(@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.43)(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) @@ -324,6 +326,9 @@ importers: '@opentelemetry/sdk-trace-node': specifier: ^2.11.0 version: 2.11.0(@opentelemetry/api@1.9.1) + es-module-lexer: + specifier: ^3.0.2 + version: 3.0.2 eslint: specifier: ^10.10.0 version: 10.11.0(jiti@2.7.0)(supports-color@10.2.2) @@ -5271,9 +5276,8 @@ packages: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} - nitropack@https://pkg.pr.new/nitrojs/nitro/nitropack@4651: - resolution: {integrity: sha512-3uduCldBwnejOgC7I1PCcG9Ktithuj5zda8h8VKsfi5J85DWew2q7VDFbP9KbKOJLbMdC1pubp3mmoblNNozeg==, tarball: https://pkg.pr.new/nitrojs/nitro/nitropack@4651} - version: 2.13.4 + nitropack@2.13.4: + resolution: {integrity: sha512-tX7bT6zxNeMwkc6hxHiZeUoTOjVrcjoh1Z3cmxOlodIqjl4HISgqfGOmkWSayky3Nv9Z5+KQH52F8nmXJY5AAA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8175,12 +8179,14 @@ snapshots: - rolldown - unplugin - '@nuxt/devtools-kit@4.0.0-alpha.20(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))))(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))': + '@nuxt/devtools-kit@4.0.0-alpha.20(@nuxt/kit@4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))))(nitropack@2.13.4(patch_hash=20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b)(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))': dependencies: '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))) nostics: 1.3.0 tinyexec: 1.3.1 vite: 8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1) + optionalDependencies: + nitropack: 2.13.4(patch_hash=20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b)(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) '@nuxt/devtools-kit@4.0.0-alpha.7(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)))(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))': dependencies: @@ -8570,7 +8576,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 my-bad: 0.2.10(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) - nitropack: https://pkg.pr.new/nitrojs/nitro/nitropack@4651(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + nitropack: 2.13.4(patch_hash=20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b)(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) nostics: 1.3.0 nuxt: 4.6.0(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.151.0)(@types/node@26.6.2)(@vercel/functions@3.9.9(ws@8.21.3))(@vue/compiler-sfc@3.5.43)(acorn@8.18.0)(confbox@0.3.1)(db0@0.3.4)(dotenv@17.4.2)(esbuild@0.28.2)(eslint@10.11.0(jiti@2.7.0)(supports-color@10.2.2))(giget@3.3.1)(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(mlly@1.8.2)(optionator@0.9.4)(oxc-parser@0.151.0)(rolldown@1.2.12)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.12)(rollup@4.63.4))(rollup@4.63.4)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.51.2)(typescript@6.0.3)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.1) ohash: 2.0.12 @@ -12584,7 +12590,7 @@ snapshots: dependencies: content-type: 2.1.0 - nitropack@https://pkg.pr.new/nitrojs/nitro/nitropack@4651(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)): + nitropack@2.13.4(patch_hash=20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b)(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.63.4) @@ -12776,7 +12782,7 @@ snapshots: - rolldown - unplugin - nuxt-og-image@6.9.0(42f5d805aec283ad50adcd34e4cf6320): + nuxt-og-image@6.9.0(bd4377c344c0350ed72e6c8bb890f801): dependencies: '@clack/prompts': 1.8.1 '@nuxt/kit': 4.6.0(@nuxt/schema@4.6.0)(confbox@0.3.1)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magic-string@1.4.2)(mlly@1.8.2)(oxc-parser@0.151.0)(rolldown@1.2.12)(unplugin@3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.63.4)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1))) @@ -12813,6 +12819,7 @@ snapshots: optionalDependencies: '@resvg/resvg-js': 2.6.2 fontless: 0.2.1(@vercel/functions@3.9.9(ws@8.21.3))(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) + nitropack: 2.13.4(patch_hash=20f742d396e722631be1a449ba2e342173636d3ea145eef990ac24c4be8f253b)(@vercel/functions@3.9.9(ws@8.21.3))(oxc-parser@0.151.0)(rolldown@1.2.12)(srvx@0.11.22)(supports-color@10.2.2)(vite@8.3.3(@types/node@26.6.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.1)) satori: 0.33.5 sharp: 0.35.4(@types/node@26.6.2) tailwindcss: 4.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b54f9a7..d2726aa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,5 +19,9 @@ minimumReleaseAgeExclude: overrides: # Keep the workspace on a single h3 major (v1), matching comark-content. h3: ^1.15.11 - # Until nitrojs/nitro#4651 is released: header-only Vercel routes need `continue: true`. - nitropack: https://pkg.pr.new/nitrojs/nitro/nitropack@4651 + +patchedDependencies: + # Until released in Nitro v2, two fixes on top of 2.13.4: + # - nitrojs/nitro#4651: header-only Vercel routes need `continue: true`. + # - Externals map files back to pattern exports, or layer components importing `comark/plugins/*` crash SSR. + nitropack@2.13.4: patches/nitropack@2.13.4.patch diff --git a/test/e2e/bundle.test.ts b/test/e2e/bundle.test.ts new file mode 100644 index 0000000..529044f --- /dev/null +++ b/test/e2e/bundle.test.ts @@ -0,0 +1,50 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { isBuiltin } from 'node:module' +import { join, relative } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { init, parse } from 'es-module-lexer' +import { resolveModuleURL } from 'exsolve' +import { expect, it } from 'vitest' + +const serverDir = fileURLToPath(new URL('../../playground/.output/server/', import.meta.url)) + +// Optional peers imported behind a try/catch, absent unless a site installs them. +const OPTIONAL_IMPORTS = new Set([ + // `@nuxtjs/mcp-toolkit` Code Mode. + 'secure-exec', +]) + +/** The bundled chunks: the traced `node_modules/` are third-party files Nitro copied as they are. */ +function bundledFiles(): string[] { + return readdirSync(serverDir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.mjs')) + .map((entry) => join(entry.parentPath, entry.name)) + .filter((file) => !relative(serverDir, file).startsWith('node_modules')) +} + +// A lazy chunk (a component, a route) only fails once requested, so the route crawl alone can miss it. +it('every import in the server bundle resolves inside the build output', async () => { + await init() + const files = bundledFiles() + const broken: string[] = [] + let checked = 0 + + for (const file of files) { + const [imports] = parse(readFileSync(file, 'utf8')) + for (const entry of imports) { + // `import.meta`, computed dynamic imports and template globs name no single module. + const specifier = entry.type === 'dynamic' && entry.glob ? undefined : entry.specifier + if (!specifier || isBuiltin(specifier) || OPTIONAL_IMPORTS.has(specifier)) continue + checked++ + const resolved = resolveModuleURL(specifier, { from: pathToFileURL(file), try: true }) + // Resolving into the repo's own `node_modules` works locally but not once deployed. + if (!resolved?.startsWith(pathToFileURL(serverDir).href)) { + broken.push(`${relative(serverDir, file)} imports ${specifier}`) + } + } + } + + // Guards against a vacuous pass, e.g. the lexer reporting no specifiers after an API change. + expect(checked).toBeGreaterThan(files.length) + expect(broken).toEqual([]) +}) diff --git a/test/e2e/routes.test.ts b/test/e2e/routes.test.ts new file mode 100644 index 0000000..ea933d2 --- /dev/null +++ b/test/e2e/routes.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, inject, it } from 'vitest' + +const baseURL = inject('baseURL') + +/** Fetches a site path, explaining a failure with the message Nitro attaches to its JSON errors. */ +async function get(path: string, headers?: HeadersInit) { + const response = await fetch(new URL(path, baseURL), { headers }) + let failure = `${path} returned ${response.status}` + if (response.status >= 400) { + const error = await fetch(new URL(path, baseURL), { headers: { accept: 'application/json' } }) + .then((res) => res.json() as Promise<{ message?: string }>) + .catch(() => undefined) + if (error?.message) failure += `: ${error.message}` + } + return { response, failure } +} + +/** Absolute URLs point at the production site: keep the path and query only. */ +function toPath(url: string): string { + const { pathname, search } = new URL(url.replaceAll('&', '&')) + return pathname + search +} + +// The sitemap lists every content page plus the Vue pages, so a new page is covered without touching this file. +const sitemap = await fetch(new URL('/sitemap.xml', baseURL)).then((res) => res.text()) +const pages = [...sitemap.matchAll(/([^<]+)<\/loc>/g)].map((match) => toPath(match[1]!)) + +describe('pages', () => { + it('lists the landing page and the docs', () => { + expect(pages).toContain('/') + expect(pages.length).toBeGreaterThan(5) + }) + + it.each(pages)('%s renders, OG image included', async (path) => { + const { response, failure } = await get(path) + expect(response.status, failure).toBe(200) + expect(response.headers.get('content-type')).toContain('text/html') + + const ogImage = (await response.text()).match(/ { + it.each([ + '/raw/index.md', + '/raw/getting-started/introduction.md', + '/llms.txt', + '/llms-full.txt', + '/sitemap.md', + '/rss.xml', + '/openapi.json', + '/robots.txt', + ])('%s responds', async (path) => { + const { response, failure } = await get(path) + expect(response.status, failure).toBe(200) + }) + + // Cached routes answer with a redirect to the `/raw` twin rather than a rewrite. + it('negotiates markdown on a page URL', async () => { + const { response, failure } = await get('/getting-started/introduction', { accept: 'text/markdown' }) + expect(response.status, failure).toBe(200) + expect(response.headers.get('content-type')).toContain('text/markdown') + }) +}) diff --git a/test/e2e/setup.ts b/test/e2e/setup.ts new file mode 100644 index 0000000..f2ef09a --- /dev/null +++ b/test/e2e/setup.ts @@ -0,0 +1,67 @@ +import { type ChildProcess, spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { type AddressInfo, createServer } from 'node:net' +import { fileURLToPath } from 'node:url' +import type { TestProject } from 'vitest/node' + +declare module 'vitest' { + export interface ProvidedContext { + baseURL: string + } +} + +const entry = fileURLToPath(new URL('../../playground/.output/server/index.mjs', import.meta.url)) + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer() + probe.on('error', reject) + probe.listen(0, '127.0.0.1', () => { + const { port } = probe.address() as AddressInfo + probe.close(() => resolve(port)) + }) + }) +} + +async function waitForServer(url: string, server: ChildProcess) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`The playground server exited with code ${server.exitCode}`) + } + try { + await fetch(url) + return + } catch { + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } + throw new Error(`The playground server did not answer on ${url} within 30s`) +} + +/** Boots the built playground once for the whole e2e project. */ +export default async function setup(project: TestProject) { + if (!existsSync(entry)) { + throw new Error('No playground build found: run `pnpm build` before `pnpm test:e2e`.') + } + + const port = await freePort() + const server = spawn(process.execPath, [entry], { + env: { ...process.env, HOST: '127.0.0.1', PORT: String(port) }, + // Server logs land in the test output: the `[request error]` line is what explains a failing route. + stdio: ['ignore', 'inherit', 'inherit'], + }) + + const baseURL = `http://127.0.0.1:${port}` + try { + await waitForServer(`${baseURL}/robots.txt`, server) + } catch (error) { + server.kill() + throw error + } + project.provide('baseURL', baseURL) + + return () => { + server.kill() + } +} diff --git a/vitest.config.ts b/vitest.config.ts index bb41451..0497dce 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,8 +8,27 @@ export default defineConfig({ // `transformSync`, hence the cast. oxc: { tsconfig: false } as never, test: { - include: ['test/**/*.test.ts', 'modules/**/*.test.ts'], - setupFiles: ['test/setup.ts'], - environment: 'node', + projects: [ + { + test: { + name: 'unit', + include: ['test/**/*.test.ts', 'modules/**/*.test.ts'], + exclude: ['test/e2e/**'], + setupFiles: ['test/setup.ts'], + environment: 'node', + }, + }, + { + // Runs against the built playground (`pnpm build`), so it catches what only breaks once bundled. + test: { + name: 'e2e', + include: ['test/e2e/**/*.test.ts'], + globalSetup: ['test/e2e/setup.ts'], + environment: 'node', + // A cold page pulls its content from GitHub and OG images render through satori. + testTimeout: 60_000, + }, + }, + ], }, }) From e0799d03766193dd7c483112e55c9446c5da57e2 Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Tue, 13 Oct 2026 22:08:45 +0200 Subject: [PATCH 2/2] Update .github/workflows/ci.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcd3dba..97052e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - run: pnpm test:e2e env: GITHUB_TOKEN: ${{ github.token }} - NUXT_DOCS_GITHUB_BRANCH: ${{ github.event.pull_request.head.sha || github.sha }} + NUXT_DOCS_GITHUB_BRANCH: ${{ github.sha }} # Continuous preview releases via pkg.pr.new: every commit gets an installable # tarball (https://pkg.pr.new/comarkdown/comark-docs@), so downstream sites # can test the layer before a release. Requires the pkg.pr.new GitHub App.