diff --git a/budget.json b/budget.json index 3876d0f1..3febedb6 100644 --- a/budget.json +++ b/budget.json @@ -4,19 +4,19 @@ "resourceSizes": [ { "resourceType": "document", - "budget": 12.9 + "budget": 13 }, { "resourceType": "script", - "budget": 65.2 + "budget": 54.5 }, { "resourceType": "stylesheet", - "budget": 0.3 + "budget": 0 }, { "resourceType": "other", - "budget": 27.9 + "budget": 35.3 } ], "resourceCounts": [ @@ -26,7 +26,7 @@ }, { "resourceType": "stylesheet", - "budget": 1 + "budget": 0 }, { "resourceType": "script", diff --git a/nuxt.config.ts b/nuxt.config.ts index df9b56a2..f091d2c5 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -104,6 +104,7 @@ export default defineNuxtConfig({ nitro: { prerender: { + concurrency: 12, crawlLinks: true, routes: ['/', '/uses', '/og/og.jpg', '/rss.xml'], }, diff --git a/package.json b/package.json index 6070771a..b5aa1904 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "globby": "^13.1.4", "gray-matter": "4.0.3", "jose": "4.14.4", + "knitwork": "^1.0.0", "magic-regexp": "0.7.0", "magic-string": "^0.30.0", "mlly": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c61a9e6..3543c395 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ dependencies: jose: specifier: 4.14.4 version: 4.14.4 + knitwork: + specifier: ^1.0.0 + version: 1.0.0 magic-regexp: specifier: 0.7.0 version: 0.7.0 diff --git a/src/modules/router.ts b/src/modules/router.ts new file mode 100644 index 00000000..e44184ab --- /dev/null +++ b/src/modules/router.ts @@ -0,0 +1,122 @@ +import { statSync } from 'node:fs' +import fsp from 'node:fs/promises' + +import { join, relative } from 'pathe' +import { genArrayFromRaw, genDynamicImport, genString } from 'knitwork' +import { + addComponent, + addImports, + addTemplate, + addRouteMiddleware, + createResolver, + defineNuxtModule, + useNuxt, +} from 'nuxt/kit' +import { withLeadingSlash } from 'ufo' + +const routeMeta: Record> = { + '/blog': { + title: 'Blog', + }, + '/talks': { + title: 'Talks', + }, + '/uses': { + title: 'Uses', + }, + '/work': { + title: 'Work', + }, +} + +export default defineNuxtModule({ + meta: { + name: 'custom-router', + }, + setup() { + const nuxt = useNuxt() + const resolver = createResolver(import.meta.url) + nuxt.options.pages = false + + nuxt.hook('app:templates', app => { + const routerTemplate = app.templates.find( + t => t.filename === 'vue-router.d.ts' + ) + routerTemplate!.getContents = () => 'export * from "vue-router"' + }) + + addImports({ + name: 'definePageMeta', + from: resolver.resolve('./runtime/route-composables'), + priority: 20, + }) + + addImports({ + name: 'useRoute', + from: resolver.resolve('./runtime/route-composables'), + priority: 20, + }) + + addComponent({ + filePath: resolver.resolve('./runtime/nuxt-link'), + name: 'NuxtLink', + priority: 20, + }) + + addComponent({ + filePath: resolver.resolve('./runtime/NuxtPage.vue'), + name: 'NuxtPage', + priority: 20, + }) + + addRouteMiddleware({ + name: 'path-params', + global: true, + path: resolver.resolve('./runtime/path.global.ts'), + }) + + addTemplate({ + filename: 'routes.mjs', + async getContents() { + const files = await readRecursive(resolver.resolve('../pages')) + return ` + import { defineAsyncComponent } from 'vue'; + export default ${genArrayFromRaw( + files.map(f => { + const path = withLeadingSlash( + relative(resolver.resolve('../pages'), f).replace( + /(\/?index)?\.vue$/, + '' + ) + ) + return { + path: path.includes('[') + ? `/${path + .replace(/\[(.*)\]/g, '(?<$1>.+)') + .replace(/\//g, '\\/')}/` + : genString(path), + component: `defineAsyncComponent(${genDynamicImport(f, { + interopDefault: true, + })})`, + meta: JSON.stringify(routeMeta[path] || {}), + } + }) + )}` + }, + }) + }, +}) + +async function readRecursive(dir: string) { + const files = await fsp.readdir(dir) + const result: string[] = [] + for (const file of files) { + const path = join(dir, file) + if (statSync(path).isDirectory()) { + result.push(...(await readRecursive(path))) + } else { + result.push(path) + } + } + return result +} diff --git a/src/modules/runtime/NuxtPage.vue b/src/modules/runtime/NuxtPage.vue new file mode 100644 index 00000000..005f4aa5 --- /dev/null +++ b/src/modules/runtime/NuxtPage.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/modules/runtime/nuxt-link.ts b/src/modules/runtime/nuxt-link.ts new file mode 100644 index 00000000..f7d38fe5 --- /dev/null +++ b/src/modules/runtime/nuxt-link.ts @@ -0,0 +1,327 @@ +import type { ComputedRef, DefineComponent, PropType } from 'vue' +import { computed, defineComponent, h, ref, resolveComponent } from 'vue' +import { hasProtocol } from 'ufo' + +import { useRouter } from '#app/composables/router' +import { useNuxtApp } from '#app/nuxt' +import type { RouteLocationRaw } from '#vue-router' + +const firstNonUndefined = (...args: (T | undefined)[]) => + args.find(arg => arg !== undefined) + +const DEFAULT_EXTERNAL_REL_ATTRIBUTE = 'noopener noreferrer' + +export type NuxtLinkOptions = { + componentName?: string + externalRelAttribute?: string | null + activeClass?: string + exactActiveClass?: string + prefetchedClass?: string + trailingSlash?: 'append' | 'remove' +} + +export type NuxtLinkProps = { + // Routing + to?: RouteLocationRaw + href?: RouteLocationRaw + external?: boolean + replace?: boolean + custom?: boolean + + // Attributes + target?: '_blank' | '_parent' | '_self' | '_top' | (string & {}) | null + rel?: string | null + noRel?: boolean + + prefetch?: boolean + noPrefetch?: boolean + + // Styling + activeClass?: string + exactActiveClass?: string + + // Vue Router's `` additional props + ariaCurrentValue?: string +} + +export default defineComponent({ + name: 'NuxtLink', + props: { + // Routing + to: { + type: [String, Object] as PropType, + default: undefined, + required: false, + }, + href: { + type: [String, Object] as PropType, + default: undefined, + required: false, + }, + + // Attributes + target: { + type: String as PropType, + default: undefined, + required: false, + }, + rel: { + type: String as PropType, + default: undefined, + required: false, + }, + noRel: { + type: Boolean as PropType, + default: undefined, + required: false, + }, + + // Prefetching + prefetch: { + type: Boolean as PropType, + default: undefined, + required: false, + }, + noPrefetch: { + type: Boolean as PropType, + default: undefined, + required: false, + }, + + // Styling + activeClass: { + type: String as PropType, + default: undefined, + required: false, + }, + exactActiveClass: { + type: String as PropType, + default: undefined, + required: false, + }, + prefetchedClass: { + type: String as PropType, + default: undefined, + required: false, + }, + + // Vue Router's `` additional props + replace: { + type: Boolean as PropType, + default: undefined, + required: false, + }, + ariaCurrentValue: { + type: String as PropType, + default: undefined, + required: false, + }, + + // Edge cases handling + external: { + type: Boolean as PropType, + default: undefined, + required: false, + }, + }, + setup(props, { slots }) { + const router = useRouter() + + // Resolving `to` value from `to` and `href` props + // Defaults to empty string (won't render any `href` attribute) + const to: ComputedRef = computed( + () => props.to || props.href || '' + ) + + // Resolving link type + const isExternal = computed(() => { + // External prop is explicitly set + if (props.external) { + return true + } + + // When `target` prop is set, link is external + if (props.target && props.target !== '_self') { + return true + } + + // When `to` is a route object then it's an internal link + if (typeof to.value === 'object') { + return false + } + + return to.value === '' || hasProtocol(to.value, { acceptRelative: true }) + }) + + // Prefetching + const prefetched = ref(false) + const el = process.server ? undefined : ref(null) + const elRef = process.server + ? undefined + : (ref: any) => { + el!.value = ref?.$el + } + + if (process.client) { + const shouldPrefetch = + props.prefetch !== false && + props.noPrefetch !== true && + props.target !== '_blank' && + !isSlowConnection() + + if (shouldPrefetch) { + const nuxtApp = useNuxtApp() + let idleId: number + let unobserve: (() => void) | null = null + onMounted(() => { + const observer = useObserver() + onNuxtReady(() => { + idleId = requestIdleCallback(() => { + if (el?.value?.tagName) { + unobserve = observer!.observe( + el.value as HTMLElement, + async () => { + unobserve?.() + unobserve = null + + const path = + typeof to.value === 'string' + ? to.value + : router.resolve(to.value).fullPath + await Promise.all([ + nuxtApp.hooks + .callHook('link:prefetch', path) + .catch(() => {}), + !isExternal.value && + preloadRouteComponents( + to.value as string, + router + ).catch(() => {}), + ]) + prefetched.value = true + } + ) + } + }) + }) + }) + onBeforeUnmount(() => { + if (idleId) { + cancelIdleCallback(idleId) + } + unobserve?.() + unobserve = null + }) + } + } + + return () => { + if (!isExternal.value) { + const routerLinkProps: Record = { + ref: elRef, + to: to.value, + activeClass: props.activeClass, + exactActiveClass: props.exactActiveClass, + replace: props.replace, + ariaCurrentValue: props.ariaCurrentValue, + } + + // `custom` API cannot support fallthrough attributes as the slot + // may render fragment or text root nodes (#14897, #19375) + if (prefetched.value) { + routerLinkProps.class = props.prefetchedClass + } + routerLinkProps.rel = props.rel + + // Internal link + return h(resolveComponent('RouterLink'), routerLinkProps, slots.default) + } + + // Resolves `to` value if it's a route location object + // converts `""` to `null` to prevent the attribute from being added as empty (`href=""`) + const href = + typeof to.value === 'object' + ? router.resolve(to.value)?.href ?? null + : to.value || null + + // Resolves `target` value + const target = props.target || null + + // Resolves `rel` + const rel = props.noRel + ? null + : // converts `""` to `null` to prevent the attribute from being added as empty (`rel=""`) + firstNonUndefined( + props.rel, + href ? DEFAULT_EXTERNAL_REL_ATTRIBUTE : '' + ) || null + + return h('a', { ref: el, href, rel, target }, slots.default?.()) + } + }, +}) as unknown as DefineComponent + +// --- Prefetching utils --- +type CallbackFn = () => void +type ObserveFn = (element: Element, callback: CallbackFn) => () => void + +function useObserver(): { observe: ObserveFn } | undefined { + if (process.server) { + return + } + + const nuxtApp = useNuxtApp() + if (nuxtApp._observer) { + return nuxtApp._observer + } + + let observer: IntersectionObserver | null = null + + const callbacks = new Map() + + const observe: ObserveFn = (element, callback) => { + if (!observer) { + observer = new IntersectionObserver(entries => { + for (const entry of entries) { + const callback = callbacks.get(entry.target) + const isVisible = entry.isIntersecting || entry.intersectionRatio > 0 + if (isVisible && callback) { + callback() + } + } + }) + } + callbacks.set(element, callback) + observer.observe(element) + return () => { + callbacks.delete(element) + observer!.unobserve(element) + if (callbacks.size === 0) { + observer!.disconnect() + observer = null + } + } + } + + const _observer = (nuxtApp._observer = { + observe, + }) + + return _observer +} + +function isSlowConnection() { + if (process.server) { + return + } + + // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection + const cn = (navigator as any).connection as { + saveData: boolean + effectiveType: string + } | null + if (cn && (cn.saveData || /2g/.test(cn.effectiveType))) { + return true + } + return false +} diff --git a/src/modules/runtime/path.global.ts b/src/modules/runtime/path.global.ts new file mode 100644 index 00000000..69fdee0d --- /dev/null +++ b/src/modules/runtime/path.global.ts @@ -0,0 +1,11 @@ +import { match } from './routes' + +export default defineNuxtRouteMiddleware(to => { + const path = to.path + const matched = match(path) + Object.assign(to.meta, matched?.meta) + if (matched?.path instanceof RegExp) { + to.params = + (path.match(matched.path)?.groups as Record) || {} + } +}) diff --git a/src/modules/runtime/route-composables.ts b/src/modules/runtime/route-composables.ts new file mode 100644 index 00000000..76c7e85c --- /dev/null +++ b/src/modules/runtime/route-composables.ts @@ -0,0 +1,4 @@ +/*! @__NO_SIDE_EFFECTS__ */ +export const definePageMeta = (_args: any) => {} + +export const useRoute = (_path?: string) => useNuxtApp().$route diff --git a/src/modules/runtime/routes.ts b/src/modules/runtime/routes.ts new file mode 100644 index 00000000..3e64a6be --- /dev/null +++ b/src/modules/runtime/routes.ts @@ -0,0 +1,14 @@ +// @ts-expect-error virtual file +import _routes from '#build/routes' + +interface NuxtRoute { + path: string | RegExp + component: any + meta?: Record +} + +export const routes: NuxtRoute[] = _routes +export const match = (path: string) => + routes.find(r => + typeof r.path === 'string' ? r.path === path : r.path.test(path) + ) diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts index d7326356..85765639 100644 --- a/test/unit/bundle.spec.ts +++ b/test/unit/bundle.spec.ts @@ -35,7 +35,7 @@ describe('project sizes', () => { it('default client bundle size', async () => { stats.client = await analyzeSizes('**/*.js', publicDir) expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot( - '"212k"' + '"184k"' ) expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))) .toMatchInlineSnapshot(`