├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE.md ├── README.md ├── build.config.ts ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── runtime.d.ts ├── src ├── index.ts ├── runtime.ts ├── types.ts ├── utils.ts ├── vite.ts └── webpack.ts ├── test ├── compat.test.ts ├── dependencies.test.ts ├── fixtures │ ├── vite-manifest.json │ └── webpack-manifest.json ├── manifest.test.ts ├── renderer.test.ts └── types.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | ci: 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | - run: npm i -g --force corepack && corepack enable 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 18 23 | cache: "pnpm" 24 | - run: pnpm install 25 | - run: pnpm lint 26 | - run: pnpm build 27 | - run: pnpm vitest --coverage 28 | - run: pnpm tsc --noEmit 29 | - uses: codecov/codecov-action@v5 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | types 4 | coverage 5 | *.log 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.useFlatConfig": true 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## v2.1.1 6 | 7 | [compare changes](https://github.com/nuxt-contrib/vue-bundle-renderer/compare/v2.1.0...v2.1.1) 8 | 9 | ### 🩹 Fixes 10 | 11 | - Add `crossorigin` attribute for stylesheets ([#77](https://github.com/nuxt-contrib/vue-bundle-renderer/pull/77)) 12 | 13 | ### 🏡 Chore 14 | 15 | - Add CODEOWNERS ([cad3b5b](https://github.com/nuxt-contrib/vue-bundle-renderer/commit/cad3b5b)) 16 | - Use npm publish ([1ccc0f9](https://github.com/nuxt-contrib/vue-bundle-renderer/commit/1ccc0f9)) 17 | 18 | ### ❤️ Contributors 19 | 20 | - Daniel Roe ([@danielroe](http://github.com/danielroe)) 21 | 22 | ## v2.1.0 23 | 24 | [compare changes](https://github.com/nuxt-contrib/vue-bundle-renderer/compare/v2.0.0...v2.1.0) 25 | 26 | ### 🩹 Fixes 27 | 28 | - Treat `.pcss` extension as a CSS extension ([#69](https://github.com/nuxt-contrib/vue-bundle-renderer/pull/69)) 29 | - Improve type safety of `ssrContext` and `createRenderer` ([#73](https://github.com/nuxt-contrib/vue-bundle-renderer/pull/73)) 30 | 31 | ### 🏡 Chore 32 | 33 | - Migrate to eslint v9 ([#74](https://github.com/nuxt-contrib/vue-bundle-renderer/pull/74)) 34 | - Remove explicit dev dependency on `expect-type` ([bc09fa9](https://github.com/nuxt-contrib/vue-bundle-renderer/commit/bc09fa9)) 35 | - Add prepack step ([7ec1ec8](https://github.com/nuxt-contrib/vue-bundle-renderer/commit/7ec1ec8)) 36 | 37 | ### 🤖 CI 38 | 39 | - Test against node v18 ([f080973](https://github.com/nuxt-contrib/vue-bundle-renderer/commit/f080973)) 40 | 41 | ### ❤️ Contributors 42 | 43 | - Daniel Roe ([@danielroe](http://github.com/danielroe)) 44 | - Aman Desai ([@amandesai01](http://github.com/amandesai01)) 45 | 46 | ## v2.0.0 47 | 48 | [compare changes](https://undefined/undefined/compare/v1.0.3...v2.0.0) 49 | 50 | ### 🚀 Enhancements 51 | 52 | - ⚠️ Add `preload` and `prefetch` attributes to manifest (#50) 53 | 54 | ### 🩹 Fixes 55 | 56 | - Add crossorigin tag for script preload/prefetch (c05fe93) 57 | - Don't hint to preload/prefetch styles loaded on page (#51) 58 | 59 | ### 🏡 Chore 60 | 61 | - Update renovate config (b682845) 62 | - Bump all dependencies (e9c6408) 63 | 64 | ### ✅ Tests 65 | 66 | - Update tests (ad2e20e) 67 | 68 | #### ⚠️ Breaking Changes 69 | 70 | - ⚠️ Add `preload` and `prefetch` attributes to manifest (#50) 71 | 72 | ### ❤️ Contributors 73 | 74 | - Daniel Roe 75 | 76 | ## v1.0.3 77 | 78 | [compare changes](https://undefined/undefined/compare/v1.0.2...v1.0.3) 79 | 80 | 81 | ### 🩹 Fixes 82 | 83 | - Eagerly initialise dependencies cache (#47) 84 | - Only inject entry scripts as `` 280 | } 281 | 282 | function renderLinkToString(attrs: LinkAttributes) { 283 | return ` value === null ? '' : value ? ` ${key}="${value}"` : ' ' + key).join('')}>` 284 | } 285 | 286 | function renderLinkToHeader(attrs: LinkAttributes) { 287 | return `<${attrs.href}>${Object.entries(attrs).map(([key, value]) => key === 'href' || value === null ? '' : value ? `; ${key}="${value}"` : `; ${key}`).join('')}` 288 | } 289 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface ResourceMeta { 2 | // https://github.com/vitejs/vite/blob/main/packages/vite/src/node/plugins/manifest.ts#L8-L19 3 | src?: string 4 | name?: string 5 | file: string 6 | css?: string[] 7 | assets?: string[] 8 | isEntry?: boolean 9 | isDynamicEntry?: boolean 10 | sideEffects?: boolean 11 | imports?: string[] 12 | dynamicImports?: string[] 13 | // Augmentations for vue-bundle-renderer 14 | module?: boolean 15 | prefetch?: boolean 16 | preload?: boolean 17 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Link_types/preload#what_types_of_content_can_be_preloaded 18 | resourceType?: 'audio' | 'document' | 'embed' | 'fetch' | 'font' | 'image' | 'object' | 'script' | 'style' | 'track' | 'worker' | 'video' 19 | mimeType?: string 20 | } 21 | 22 | export interface Manifest { 23 | [key: string]: ResourceMeta 24 | } 25 | 26 | export function defineManifest(manifest: Manifest): Manifest { 27 | return manifest 28 | } 29 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ResourceMeta } from './types' 2 | 3 | const IS_JS_RE = /\.[cm]?js(?:\?[^.]+)?$/ 4 | const HAS_EXT_RE = /[^./]+\.[^./]+$/ 5 | const IS_CSS_RE = /\.(?:css|postcss|pcss|sass|scss|less|stylus|styl)(?:\?[^.]+)?$/ 6 | 7 | export function isJS(file: string) { 8 | return IS_JS_RE.test(file) || !HAS_EXT_RE.test(file) 9 | } 10 | 11 | export function isCSS(file: string) { 12 | return IS_CSS_RE.test(file) 13 | } 14 | 15 | const IMAGE_RE = /^(?:jpe?g|png|svg|gif|webp|ico)$/ 16 | const FONT_RE = /^(?:woff2?|ttf|otf|eot)$/ 17 | const AUDIO_RE = /^(?:mp3|wav|ogg|flac|aac|m4a|wma|aiff|aif|au|raw|vox|opus)$/ 18 | const VIDEO_RE = /^(?:mp4|webm|ogv|mkv|avi|mov|flv|wmv|mpg|mpeg|m4v|3gp|3g2|mxf|rm|rmvb|asf|asx|m3u8|m3u|pls|cue)$/ 19 | 20 | const contentTypeMap: Record = { 21 | ico: 'image/x-icon', 22 | jpg: 'image/jpeg', 23 | svg: 'image/svg+xml', 24 | } 25 | 26 | export function getContentType(asType: ResourceMeta['resourceType'], extension: string) { 27 | if (asType === 'font') { 28 | return `font/${extension}` 29 | } 30 | if (asType === 'image') { 31 | return contentTypeMap[extension] || `image/${extension}` 32 | } 33 | } 34 | 35 | export function getAsType(ext: string): ResourceMeta['resourceType'] { 36 | if (ext === 'js' || ext === 'cjs' || ext === 'mjs') { 37 | return 'script' 38 | } 39 | else if (ext === 'css') { 40 | return 'style' 41 | } 42 | else if (IMAGE_RE.test(ext)) { 43 | return 'image' 44 | } 45 | else if (FONT_RE.test(ext)) { 46 | return 'font' 47 | } 48 | else if (AUDIO_RE.test(ext)) { 49 | return 'audio' 50 | } 51 | else if (VIDEO_RE.test(ext)) { 52 | return 'video' 53 | } 54 | // not exhausting all possibilities here, but above covers common cases 55 | } 56 | 57 | export const parseResource = (path: string) => { 58 | const chunk: Omit = {} 59 | 60 | const extension = path.replace(/\?.*/, '').split('.').pop() || '' 61 | 62 | const asType = getAsType(extension) 63 | if (asType) { 64 | chunk.resourceType = asType 65 | 66 | if (asType === 'script' && extension !== 'cjs') { 67 | chunk.module = true 68 | } 69 | } 70 | 71 | if (chunk.resourceType !== 'font') { 72 | chunk.prefetch = true 73 | } 74 | 75 | if (chunk.resourceType && ['module', 'script', 'style'].includes(chunk.resourceType)) { 76 | chunk.preload = true 77 | } 78 | 79 | const contentType = getContentType(asType, extension) 80 | if (contentType) { 81 | chunk.mimeType = contentType 82 | } 83 | 84 | return chunk 85 | } 86 | -------------------------------------------------------------------------------- /src/vite.ts: -------------------------------------------------------------------------------- 1 | import type { Manifest as ViteManifest } from 'vite' 2 | import type { Manifest } from './types' 3 | import { parseResource } from './utils' 4 | 5 | export function normalizeViteManifest(manifest: ViteManifest | Manifest): Manifest { 6 | const _manifest: Manifest = {} 7 | 8 | for (const file in manifest) { 9 | const chunk = manifest[file] 10 | _manifest[file] = { ...parseResource(chunk.file || file), ...chunk } 11 | for (const item of chunk.css || []) { 12 | if (!_manifest[item]) { 13 | _manifest[item] = { file: item, resourceType: 'style', ...parseResource(item) } 14 | } 15 | } 16 | for (const item of chunk.assets || []) { 17 | if (!_manifest[item]) { 18 | _manifest[item] = { file: item, ...parseResource(item) } 19 | } 20 | } 21 | } 22 | 23 | return _manifest 24 | } 25 | -------------------------------------------------------------------------------- /src/webpack.ts: -------------------------------------------------------------------------------- 1 | import type { Manifest } from './types' 2 | import { isJS, isCSS, parseResource } from './utils' 3 | 4 | // Comment out in dev mode for better type support 5 | // const type = Symbol('type') 6 | // type As = T & { [type]: L } 7 | type Identifier = string // & As 8 | type OutputPath = string // & As 9 | 10 | // Vue2 Webpack client manifest format 11 | export interface WebpackClientManifest { 12 | publicPath: string 13 | all: Array 14 | initial: Array 15 | async: Array 16 | modules: Record> 17 | hasNoCssVersion?: { [file: string]: boolean } 18 | } 19 | 20 | export function normalizeWebpackManifest(manifest: WebpackClientManifest): Manifest { 21 | // Upgrade webpack manifest 22 | // https://github.com/nuxt-contrib/vue-bundle-renderer/issues/12 23 | const clientManifest: Manifest = {} 24 | 25 | // Initialize with all keys 26 | for (const outfile of manifest.all) { 27 | if (isJS(outfile)) { 28 | clientManifest[getIdentifier(outfile)] = { 29 | file: outfile, 30 | ...parseResource(outfile), 31 | } 32 | } 33 | } 34 | 35 | // Prepare first entrypoint to receive extra data 36 | const first = getIdentifier(manifest.initial.find(isJS)!) 37 | if (first) { 38 | if (!(first in clientManifest)) { 39 | throw new Error( 40 | `Invalid manifest - initial entrypoint not in \`all\`: ${manifest.initial.find(isJS)}`, 41 | ) 42 | } 43 | clientManifest[first].css = [] 44 | clientManifest[first].assets = [] 45 | clientManifest[first].dynamicImports = [] 46 | } 47 | 48 | for (const outfile of manifest.initial) { 49 | if (isJS(outfile)) { 50 | clientManifest[getIdentifier(outfile)].isEntry = true 51 | } 52 | else if (isCSS(outfile) && first) { 53 | clientManifest[first].css!.push(outfile) 54 | clientManifest[outfile] = { file: outfile, ...parseResource(outfile) } 55 | } 56 | else if (first) { 57 | clientManifest[first].assets!.push(outfile) 58 | clientManifest[outfile] = { file: outfile, ...parseResource(outfile) } 59 | } 60 | } 61 | 62 | for (const outfile of manifest.async) { 63 | if (isJS(outfile)) { 64 | const identifier = getIdentifier(outfile) 65 | if (!(identifier in clientManifest)) { 66 | throw new Error(`Invalid manifest - async module not in \`all\`: ${outfile}`) 67 | } 68 | clientManifest[identifier].isDynamicEntry = true 69 | clientManifest[identifier].sideEffects = true 70 | clientManifest[first].dynamicImports!.push(identifier) 71 | } 72 | else if (first) { 73 | // Add assets (CSS/JS) as dynamic imports to first entrypoints 74 | // as a workaround so can be prefetched. 75 | const key = isCSS(outfile) ? 'css' : 'assets' 76 | const identifier = getIdentifier(outfile) 77 | clientManifest[identifier] = { 78 | file: '' as OutputPath, 79 | [key]: [outfile], 80 | } 81 | clientManifest[outfile] = { 82 | file: outfile, 83 | ...parseResource(outfile), 84 | } 85 | clientManifest[first].dynamicImports!.push(identifier) 86 | } 87 | } 88 | 89 | // Map modules to virtual entries 90 | for (const [moduleId, importIndexes] of Object.entries(manifest.modules)) { 91 | const jsFiles = importIndexes.map(index => manifest.all[index]).filter(isJS) 92 | jsFiles.forEach((file) => { 93 | const identifier = getIdentifier(file) 94 | clientManifest[identifier] = { 95 | ...clientManifest[identifier], 96 | file, 97 | } 98 | }) 99 | 100 | const mappedIndexes = importIndexes.map(index => manifest.all[index]) 101 | clientManifest[moduleId as Identifier] = { 102 | file: '' as OutputPath, 103 | ...parseResource(moduleId), 104 | imports: jsFiles.map(id => getIdentifier(id)), 105 | css: mappedIndexes.filter(isCSS), 106 | assets: mappedIndexes.filter(i => !isJS(i) && !isCSS(i)), 107 | } 108 | 109 | for (const key of ['css', 'assets'] as const) { 110 | for (const file of clientManifest[moduleId as Identifier][key] || []) { 111 | clientManifest[file] = clientManifest[file] || { file, ...parseResource(file) } 112 | } 113 | } 114 | } 115 | 116 | return clientManifest 117 | } 118 | 119 | function getIdentifier(output: OutputPath): Identifier 120 | function getIdentifier(output?: undefined): null 121 | function getIdentifier(output?: OutputPath): null | Identifier { 122 | return output ? `_${output}` as Identifier : null 123 | } 124 | -------------------------------------------------------------------------------- /test/compat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { joinURL } from 'ufo' 3 | 4 | import { createRenderer } from '../src/runtime' 5 | import { normalizeViteManifest, normalizeWebpackManifest } from '../src' 6 | 7 | import viteManifest from './fixtures/vite-manifest.json' 8 | import webpackManifest from './fixtures/webpack-manifest.json' 9 | 10 | describe('renderer with vite manifest', () => { 11 | const getRenderer = async () => { 12 | const renderer = createRenderer(() => {}, { manifest: normalizeViteManifest(viteManifest), renderToString: () => '' }) 13 | return await renderer.renderToString({ 14 | modules: new Set([ 15 | 'app.vue', 16 | '../packages/nuxt3/src/pages/runtime/page.vue', 17 | 'pages/index.vue', 18 | ]), 19 | }) 20 | } 21 | 22 | it('renders scripts correctly', async () => { 23 | const { renderScripts } = await getRenderer() 24 | const result = renderScripts().split('').slice(0, -1).map(s => `${s}`).sort() 25 | expect(result).toMatchInlineSnapshot(` 26 | [ 27 | "", 28 | ] 29 | `) 30 | }) 31 | it('renders styles correctly', async () => { 32 | const { renderStyles } = await getRenderer() 33 | expect(renderStyles()).to.equal( 34 | '', 35 | ) 36 | }) 37 | it('renders resource hints correctly', async () => { 38 | const { renderResourceHints } = await getRenderer() 39 | const result = renderResourceHints().split('>').slice(0, -1).map(s => `${s}>`).sort() 40 | expect(result).toMatchInlineSnapshot(` 41 | [ 42 | "", 43 | "", 44 | "", 45 | "", 46 | ] 47 | `) 48 | }) 49 | }) 50 | 51 | describe('renderer with webpack manifest', () => { 52 | const getRenderer = async () => { 53 | const manifest = normalizeWebpackManifest(webpackManifest) 54 | for (const entry in manifest) { 55 | manifest[entry].module = false 56 | } 57 | const renderer = createRenderer(() => {}, { manifest, buildAssetsURL: r => joinURL(webpackManifest.publicPath, r), renderToString: () => '' }) 58 | return await renderer.renderToString({ 59 | _registeredComponents: new Set([ 60 | '4d87aad8', 61 | '630f1d84', 62 | '56940b2e', 63 | ]), 64 | }) 65 | } 66 | 67 | it('renders scripts correctly', async () => { 68 | const { renderScripts } = await getRenderer() 69 | const result = renderScripts().split('').slice(0, -1).map(s => `${s}`).sort() 70 | expect(result).toMatchInlineSnapshot(` 71 | [ 72 | "", 73 | "", 74 | "", 75 | ] 76 | `) 77 | }) 78 | it('renders styles correctly', async () => { 79 | const { renderStyles } = await getRenderer() 80 | expect(renderStyles()).to.equal( 81 | '', 82 | ) 83 | }) 84 | it('renders resource hints correctly', async () => { 85 | const { renderResourceHints } = await getRenderer() 86 | const result = renderResourceHints().split('>').slice(0, -1).map(s => `${s}>`).sort() 87 | expect(result).toEqual( 88 | [ 89 | '', 90 | '', // dynamic import 91 | '', // dynamic import CSS 92 | '', 93 | '', 94 | '', // dynamic entrypoint 95 | '', 96 | ], 97 | ) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /test/dependencies.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { joinURL } from 'ufo' 3 | 4 | import { createRendererContext, getRequestDependencies } from '../src/runtime' 5 | import { normalizeViteManifest } from '../src/vite' 6 | import viteManifest from './fixtures/vite-manifest.json' 7 | 8 | describe('dependencies', () => { 9 | const getContext = () => { 10 | return createRendererContext({ 11 | manifest: normalizeViteManifest(viteManifest), 12 | buildAssetsURL: id => joinURL('/assets', id), 13 | }) 14 | } 15 | 16 | it('gets all entrypoint dependencies', () => { 17 | const context = getContext() 18 | const { prefetch, preload, scripts, styles } = getRequestDependencies({}, context) 19 | expect(Object.values(prefetch).map(i => i.file)).toMatchInlineSnapshot(` 20 | [ 21 | "entry.png", 22 | "index.css", 23 | "index.mjs", 24 | ] 25 | `) 26 | expect(Object.values(preload).map(i => i.file)).toMatchInlineSnapshot(` 27 | [ 28 | "entry.mjs", 29 | "vendor.mjs", 30 | ] 31 | `) 32 | expect(Object.values(scripts).map(i => i.file)).toMatchInlineSnapshot(` 33 | [ 34 | "entry.mjs", 35 | ] 36 | `) 37 | expect(Object.values(styles).map(i => i.file)).toMatchInlineSnapshot(` 38 | [ 39 | "test.css", 40 | ] 41 | `) 42 | }) 43 | 44 | it('gets all dependencies for a request with dynamic imports', () => { 45 | const context = getContext() 46 | const { prefetch, preload, scripts, styles } = getRequestDependencies({ 47 | modules: new Set(['pages/about.vue']), 48 | }, context) 49 | expect(Object.values(prefetch).map(i => i.file)).toMatchInlineSnapshot(` 50 | [ 51 | "entry.png", 52 | "index.css", 53 | "index.mjs", 54 | "lazy-component.css", 55 | "lazy-component.mjs", 56 | ] 57 | `) 58 | expect(Object.values(preload).map(i => i.file)).toMatchInlineSnapshot(` 59 | [ 60 | "entry.mjs", 61 | "vendor.mjs", 62 | "about.mjs", 63 | ] 64 | `) 65 | expect(Object.values(scripts).map(i => i.file)).toMatchInlineSnapshot(` 66 | [ 67 | "entry.mjs", 68 | ] 69 | `) 70 | expect(Object.values(styles).map(i => i.file)).toMatchInlineSnapshot(` 71 | [ 72 | "test.css", 73 | "about.css", 74 | ] 75 | `) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/fixtures/vite-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "../packages/nuxt3/src/app/entry.ts": { 3 | "file": "entry.mjs", 4 | "src": "../packages/nuxt3/src/app/entry.ts", 5 | "isEntry": true, 6 | "imports": [ 7 | "_vendor.mjs" 8 | ], 9 | "css": ["test.css"], 10 | "dynamicImports": [ 11 | "pages/index.vue" 12 | ], 13 | "assets": [ 14 | "entry.png" 15 | ] 16 | }, 17 | "_vendor.mjs": { 18 | "file": "vendor.mjs" 19 | }, 20 | "pages/index.vue": { 21 | "file": "index.mjs", 22 | "src": "pages/index.vue", 23 | "isDynamicEntry": true, 24 | "imports": [ 25 | "_vendor.mjs", 26 | "../packages/nuxt3/src/app/entry.ts" 27 | ], 28 | "css": [ 29 | "index.css" 30 | ] 31 | }, 32 | "pages/about.vue": { 33 | "file": "about.mjs", 34 | "src": "pages/about.vue", 35 | "isDynamicEntry": true, 36 | "imports": [ 37 | "../packages/nuxt3/src/app/entry.ts" 38 | ], 39 | "dynamicImports": ["components/LazyComponent.vue"], 40 | "css": [ 41 | "about.css" 42 | ] 43 | }, 44 | "components/LazyComponent.vue": { 45 | "file": "lazy-component.mjs", 46 | "src": "components/LazyComponent.vue", 47 | "isDynamicEntry": true, 48 | "imports": [ 49 | "_vendor.mjs" 50 | ], 51 | "css": [ 52 | "lazy-component.css" 53 | ], 54 | "assets": [ 55 | "lazy-component.svg", 56 | "lazy-component.jpg", 57 | "lazy-component.png", 58 | "lazy-component.woff", 59 | "lazy-component.woff2", 60 | "lazy-component.mp3", 61 | "lazy-component.mp4", 62 | "lazy-component.ico" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/fixtures/webpack-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "publicPath": "/_nuxt/", 3 | "all": [ 4 | "../server/index.spa.html", 5 | "../server/index.ssr.html", 6 | "LICENSES", 7 | "app.css", 8 | "app.js", 9 | "commons/app.js", 10 | "pages/another.css", 11 | "pages/another.js", 12 | "pages/index.js", 13 | "runtime.js", 14 | "img/logo.41f2f89.svg", 15 | "some.css" 16 | ], 17 | "initial": [ 18 | "runtime.js", 19 | "commons/app.js", 20 | "app.css", 21 | "app.js" 22 | ], 23 | "async": [ 24 | "pages/another.css", 25 | "pages/another.js", 26 | "pages/index.js" 27 | ], 28 | "modules": { 29 | "4d87aad8": [3, 4], 30 | "630f1d84": [3, 4], 31 | "56940b2e": [8, 10, 11] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/manifest.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { normalizeWebpackManifest } from '../src/webpack' 4 | 5 | import webpackManifest from './fixtures/webpack-manifest.json' 6 | 7 | describe('webpack manifest', () => { 8 | it('should normalize manifest', () => { 9 | expect(normalizeWebpackManifest(webpackManifest)).toMatchInlineSnapshot(` 10 | { 11 | "4d87aad8": { 12 | "assets": [], 13 | "css": [ 14 | "app.css", 15 | ], 16 | "file": "", 17 | "imports": [ 18 | "_app.js", 19 | ], 20 | "prefetch": true, 21 | }, 22 | "56940b2e": { 23 | "assets": [ 24 | "img/logo.41f2f89.svg", 25 | ], 26 | "css": [ 27 | "some.css", 28 | ], 29 | "file": "", 30 | "imports": [ 31 | "_pages/index.js", 32 | ], 33 | "prefetch": true, 34 | }, 35 | "630f1d84": { 36 | "assets": [], 37 | "css": [ 38 | "app.css", 39 | ], 40 | "file": "", 41 | "imports": [ 42 | "_app.js", 43 | ], 44 | "prefetch": true, 45 | }, 46 | "_LICENSES": { 47 | "file": "LICENSES", 48 | "prefetch": true, 49 | }, 50 | "_app.js": { 51 | "file": "app.js", 52 | "isEntry": true, 53 | "module": true, 54 | "prefetch": true, 55 | "preload": true, 56 | "resourceType": "script", 57 | }, 58 | "_commons/app.js": { 59 | "file": "commons/app.js", 60 | "isEntry": true, 61 | "module": true, 62 | "prefetch": true, 63 | "preload": true, 64 | "resourceType": "script", 65 | }, 66 | "_pages/another.css": { 67 | "css": [ 68 | "pages/another.css", 69 | ], 70 | "file": "", 71 | }, 72 | "_pages/another.js": { 73 | "file": "pages/another.js", 74 | "isDynamicEntry": true, 75 | "module": true, 76 | "prefetch": true, 77 | "preload": true, 78 | "resourceType": "script", 79 | "sideEffects": true, 80 | }, 81 | "_pages/index.js": { 82 | "file": "pages/index.js", 83 | "isDynamicEntry": true, 84 | "module": true, 85 | "prefetch": true, 86 | "preload": true, 87 | "resourceType": "script", 88 | "sideEffects": true, 89 | }, 90 | "_runtime.js": { 91 | "assets": [], 92 | "css": [ 93 | "app.css", 94 | ], 95 | "dynamicImports": [ 96 | "_pages/another.css", 97 | "_pages/another.js", 98 | "_pages/index.js", 99 | ], 100 | "file": "runtime.js", 101 | "isEntry": true, 102 | "module": true, 103 | "prefetch": true, 104 | "preload": true, 105 | "resourceType": "script", 106 | }, 107 | "app.css": { 108 | "file": "app.css", 109 | "prefetch": true, 110 | "preload": true, 111 | "resourceType": "style", 112 | }, 113 | "img/logo.41f2f89.svg": { 114 | "file": "img/logo.41f2f89.svg", 115 | "mimeType": "image/svg+xml", 116 | "prefetch": true, 117 | "resourceType": "image", 118 | }, 119 | "pages/another.css": { 120 | "file": "pages/another.css", 121 | "prefetch": true, 122 | "preload": true, 123 | "resourceType": "style", 124 | }, 125 | "some.css": { 126 | "file": "some.css", 127 | "prefetch": true, 128 | "preload": true, 129 | "resourceType": "style", 130 | }, 131 | } 132 | `) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /test/renderer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { joinURL } from 'ufo' 3 | 4 | import { createRenderer } from '../src/runtime' 5 | import { normalizeViteManifest } from '../src/vite' 6 | import viteManifest from './fixtures/vite-manifest.json' 7 | 8 | describe('renderer', () => { 9 | const getRenderer = async (modules = [ 10 | 'app.vue', 11 | '../packages/nuxt3/src/pages/runtime/page.vue', 12 | 'pages/index.vue', 13 | ]) => { 14 | const renderer = createRenderer(() => { }, { 15 | manifest: normalizeViteManifest(viteManifest), 16 | renderToString: () => '', 17 | buildAssetsURL: id => joinURL('/assets', id), 18 | }) 19 | return await renderer.renderToString({ 20 | modules: new Set(modules), 21 | }) 22 | } 23 | 24 | it('renders scripts correctly', async () => { 25 | const { renderScripts } = await getRenderer() 26 | const result = renderScripts().split('').slice(0, -1).map(s => `${s}`) 27 | expect(result).toMatchInlineSnapshot(` 28 | [ 29 | "", 30 | ] 31 | `) 32 | }) 33 | 34 | it('renders styles correctly', async () => { 35 | const { renderStyles } = await getRenderer() 36 | expect(renderStyles().split('>').slice(0, -1).map(s => `${s}>`).sort()).toMatchInlineSnapshot( 37 | ` 38 | [ 39 | "", 40 | "", 41 | ] 42 | `, 43 | ) 44 | }) 45 | 46 | it('renders resource hints correctly', async () => { 47 | const { renderResourceHints } = await getRenderer() 48 | const result = renderResourceHints().split('>').slice(0, -1).map(s => `${s}>`).sort() 49 | expect(result).toMatchInlineSnapshot( 50 | ` 51 | [ 52 | "", 53 | "", 54 | "", 55 | "", 56 | ] 57 | `) 58 | }) 59 | 60 | it('renders resource hint headers correctly', async () => { 61 | const { renderResourceHeaders } = await getRenderer() 62 | const result = renderResourceHeaders() 63 | expect(result).toMatchInlineSnapshot(` 64 | { 65 | "link": "; rel="modulepreload"; as="script"; crossorigin, ; rel="modulepreload"; as="script"; crossorigin, ; rel="modulepreload"; as="script"; crossorigin, ; rel="prefetch"; as="image"; type="image/png"", 66 | } 67 | `) 68 | }) 69 | 70 | it('prefetches dynamic imports minimally', async () => { 71 | const { renderResourceHints } = await getRenderer([ 72 | 'pages/about.vue', 73 | ]) 74 | const result = renderResourceHints().split('>').slice(0, -1).map(s => `${s}>`).sort() 75 | expect(result).toMatchInlineSnapshot(` 76 | [ 77 | "", 78 | "", 79 | "", 80 | "", 81 | "", 82 | "", 83 | "", 84 | "", 85 | ] 86 | `) 87 | }) 88 | 89 | it('uses correct content types', async () => { 90 | const { renderResourceHints, renderStyles } = await getRenderer([ 91 | 'pages/about.vue', 92 | 'components/LazyComponent.vue', 93 | ]) 94 | expect(renderStyles().split('>').slice(0, -1).map(s => `${s}>`).sort()).toMatchInlineSnapshot(` 95 | [ 96 | "", 97 | "", 98 | "", 99 | ] 100 | `) 101 | const result = renderResourceHints().split('>').slice(0, -1).map(s => `${s}>`).sort() 102 | expect(result).toMatchInlineSnapshot(` 103 | [ 104 | "", 105 | "", 106 | "", 107 | "", 108 | "", 109 | "", 110 | "", 111 | "", 112 | "", 113 | "", 114 | "", 115 | "", 116 | "", 117 | ] 118 | `) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /test/types.test.ts: -------------------------------------------------------------------------------- 1 | import { expectTypeOf, describe, it } from 'vitest' 2 | 3 | import type { Manifest as ViteManifest } from 'vite' 4 | import type { Manifest, ResourceMeta } from '../src/types' 5 | 6 | describe('manifest', () => { 7 | it('matches vite types', () => { 8 | expectTypeOf().toMatchTypeOf() 9 | expectTypeOf().toEqualTypeOf>>() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "resolveJsonModule": true, 7 | "esModuleInterop": true, 8 | "strict": true 9 | }, 10 | "include": [ 11 | "src", 12 | "test" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------