UNPKG

nuxt-component-meta

Version:

[![npm version][npm-version-src]][npm-version-href] [![npm downloads][npm-downloads-src]][npm-downloads-href]

524 lines (518 loc) 17.8 kB
import fs, { existsSync, readFileSync } from 'fs'; import { logger, createResolver, resolveAlias, defineNuxtModule, tryResolveModule, addImportsDir, addTemplate, addServerHandler } from '@nuxt/kit'; import { join, dirname, relative } from 'pathe'; import { createUnplugin } from 'unplugin'; import { performance } from 'perf_hooks'; import { createCheckerByJson } from 'vue-component-meta'; import { resolvePathSync } from 'mlly'; import { hash } from 'ohash'; import { defu } from 'defu'; import { t as tryResolveTypesDeclaration, r as refineMeta } from './shared/nuxt-component-meta.CrgSe2qS.mjs'; import 'scule'; function useComponentMetaParser({ outputDir = join(process.cwd(), ".component-meta/"), rootDir = process.cwd(), components: _components = [], componentDirs = [], checkerOptions, exclude = [], overrides = {}, transformers = [], debug = false, metaFields, metaSources = {}, beforeWrite }) { let components = { ...metaSources }; const outputPath = join(outputDir, "component-meta"); const isExcluded = (component2) => { return exclude.find((excludeRule) => { switch (typeof excludeRule) { case "string": return component2.filePath.includes(excludeRule); case "object": return excludeRule instanceof RegExp ? excludeRule.test(component2.filePath) : false; case "function": return excludeRule(component2); default: return false; } }); }; const getStringifiedComponents = () => { const _components2 = Object.keys(components).map((key) => [ key, { ...components[key], fullPath: void 0, shortPath: void 0, export: void 0 } ]); return JSON.stringify(Object.fromEntries(_components2), null, 2); }; const getVirtualModuleContent = () => `export default ${getStringifiedComponents()}`; let checker; const refreshChecker = () => { checker = createCheckerByJson( rootDir, { extends: `${rootDir}/tsconfig.json`, skipLibCheck: true, compilerOptions: { // Ensure Nuxt virtual aliases like '#build' resolve for type analysis baseUrl: outputDir, paths: { "#build": ["."], "#build/*": ["*"] } }, include: componentDirs.map((dir) => { const path = typeof dir === "string" ? dir : dir?.path || ""; const ext = path.split(".").pop(); return ["vue", "ts", "tsx", "js", "jsx"].includes(ext) ? path : `${path}/**/*`; }), exclude: [] }, checkerOptions ); }; const init = async () => { const meta2 = await import(outputPath + ".mjs").then((m) => m.default || m).catch(() => null); for (const component2 of _components || []) { if (isExcluded(component2)) { continue; } if (!component2.filePath || !component2.pascalName) { continue; } const filePath = resolvePathSync(component2.filePath); components[component2.pascalName] = { ...component2, fullPath: filePath, filePath: relative(rootDir, filePath), meta: { type: 0, props: [], slots: [], events: [], exposed: [] } }; } if (meta2) { Object.keys(meta2).forEach((key) => { if (components[key]) { components[key].meta = meta2[key].meta; } else { components[key] = meta2[key]; } }); } }; const updateOutput = async (content) => { const path = outputPath + ".mjs"; if (beforeWrite && !content) { components = await beforeWrite(components); } if (!existsSync(dirname(path))) { fs.mkdirSync(dirname(path), { recursive: true }); } if (existsSync(path)) { fs.unlinkSync(path); } fs.writeFileSync( path, content || getVirtualModuleContent(), "utf-8" ); }; const stubOutput = () => { if (existsSync(outputPath + ".mjs")) { return; } updateOutput("export default {}"); }; const fetchComponent = (component) => { const startTime = performance.now(); try { if (typeof component === "string") { if (components[component]) { component = components[component]; } else { component = Object.entries(components).find(([, comp]) => comp.fullPath === component); if (!component) { return; } component = component[1]; } } if (!component?.fullPath || !component?.pascalName) { return; } if (component.meta.hash && component.fullPath.includes("/node_modules/")) { return; } const resolvedPath = tryResolveTypesDeclaration(component.fullPath); let code = fs.readFileSync(resolvedPath, "utf-8"); const codeHash = hash(code); if (codeHash === component.meta.hash) { return; } if (!checker) { try { refreshChecker(); } catch { return; } } if (transformers && transformers.length > 0) { for (const transform of transformers) { const transformResult = transform(component, code); component = transformResult?.component || component; code = transformResult?.code || code; } checker.updateFile(resolvedPath, code); } const meta = checker.getComponentMeta(resolvedPath); Object.assign( component.meta, refineMeta(meta, metaFields, overrides[component.pascalName] || {}), { hash: codeHash } ); const extendComponentMetaMatch = code.match(/extendComponentMeta\((\{[\s\S]*?\})\)/); const extendedComponentMeta = extendComponentMetaMatch?.length ? eval(`(${extendComponentMetaMatch[1]})`) : null; component.meta = defu(component.meta, extendedComponentMeta); components[component.pascalName] = component; } catch { if (debug) { logger.info(`Could not parse ${component?.pascalName || component?.filePath || "a component"}!`); } } const endTime = performance.now(); if (debug === 2) { logger.success(`${component?.pascalName || component?.filePath || "a component"} metas parsed in ${(endTime - startTime).toFixed(2)}ms`); } return components[component.pascalName]; }; const fetchComponents = () => { const startTime2 = performance.now(); for (const component2 of Object.values(components)) { fetchComponent(component2); } const endTime2 = performance.now(); if (!debug || debug === 2) { logger.success(`Components metas parsed in ${(endTime2 - startTime2).toFixed(2)}ms`); } }; return { get checker() { return checker; }, get components() { return components; }, dispose() { if (checker) { checker.clearCache(); } checker = null; components = {}; }, init, refreshChecker, stubOutput, outputPath, updateOutput, fetchComponent, fetchComponents, getStringifiedComponents, getVirtualModuleContent }; } const metaPlugin = createUnplugin(({ parser, parserOptions }) => { let instance = parser || useComponentMetaParser(parserOptions); let _configResolved; return { name: "vite-plugin-nuxt-component-meta", enforce: "post", async buildStart() { if (_configResolved?.build.ssr) { return; } instance?.fetchComponents(); await instance?.updateOutput(); }, buildEnd() { if (!_configResolved?.env.DEV && _configResolved?.env.PROD) { instance?.dispose(); instance = null; } }, vite: { configResolved(config) { _configResolved = config; }, async handleHotUpdate({ file }) { if (instance && Object.entries(instance.components).some(([, comp]) => comp.fullPath === file)) { instance.fetchComponent(file); await instance.updateOutput(); } } } }; }); async function loadExternalSources(sources = []) { const resolver = createResolver(import.meta.url); const components = {}; for (const src of sources) { if (typeof src === "string") { try { let modulePath = ""; const alias = resolveAlias(src); if (alias !== src) { modulePath = alias; } else { modulePath = await resolver.resolvePath(src); } const definition = await import(modulePath).then((m) => m.default || m); for (const [name, meta] of Object.entries(definition)) { components[name] = meta; } } catch (error) { logger.error(`Unable to load static components definitions from "${src}"`, error); } } else { for (const [name, meta] of Object.entries(src)) { if (meta) { components[name] = meta; } } } } return components; } const slotReplacer = (_, _before, slotName, _rest) => `<slot ${_before || ""}${slotName === "default" ? "" : `name="${slotName}"`}`; const module = defineNuxtModule({ meta: { name: "nuxt-component-meta", configKey: "componentMeta" }, defaults: (nuxt) => ({ outputDir: nuxt.options.buildDir, rootDir: nuxt.options.rootDir, componentDirs: [], components: [], metaSources: [], silent: true, exclude: [ "nuxt/dist/app/components/welcome", "nuxt/dist/app/components/client-only", "nuxt/dist/app/components/dev-only", "@nuxtjs/mdc/dist/runtime/components/MDC", "nuxt/dist/app/components/nuxt-layout", "nuxt/dist/app/components/nuxt-error-boundary", "nuxt/dist/app/components/server-placeholder", "nuxt/dist/app/components/nuxt-loading-indicator", "nuxt/dist/app/components/nuxt-route-announcer", "nuxt/dist/app/components/nuxt-stubs", (component) => component.filePath.endsWith(".svg") || component.filePath.endsWith(".d.vue.ts") ], include: [], metaFields: { type: true, props: true, slots: true, events: true, exposed: true }, transformers: [ // @nuxt/content support (component, code) => { if (code.includes("MDCSlot")) { code = code.replace(/<MDCSlot\s*([^>]*)?:use="\$slots\.([a-zA-Z0-9_]+)"/gm, slotReplacer); code = code.replace(/<MDCSlot\s*([^>]*)?name="([a-zA-Z0-9_]+)"/gm, slotReplacer); code = code.replace(/<\/MDCSlot>/gm, "</slot>"); } if (code.includes("ContentSlot")) { code = code.replace(/<ContentSlot\s*([^>]*)?:use="\$slots\.([a-zA-Z0-9_]+)"/gm, slotReplacer); code = code.replace(/<ContentSlot\s*([^>]*)?name="([a-zA-Z0-9_]+)"/gm, slotReplacer); code = code.replace(/<\/ContentSlot>/gm, "</slot>"); } const name = code.match(/(const|let|var) ([a-zA-Z][a-zA-Z-_0-9]*) = useSlots\(\)/)?.[2] || "$slots"; const _slots = code.match(new RegExp(`${name}\\.[a-zA-Z]+`, "gm")); if (_slots) { const slots = _slots.map((s) => s.replace(name + ".", "")).map((s) => `<slot name="${s}" />`); code = code.replace(/<template>/, `<template> ${slots.join("\n")} `); } const slotNames = code.match(/(const|let|var) {([^}]+)}\s*=\s*useSlots\(\)/)?.[2]; if (slotNames) { const slots = slotNames.trim().split(",").map((s) => s.trim().split(":")[0]?.trim()).map((s) => `<slot name="${s}" />`); code = code.replace(/<template>/, `<template> ${slots.join("\n")} `); } if (/declare const __VLS_export/.test(code)) { const matchWithSlots = code.match(/__VLS_WithSlots<\s*import\("vue"\)\.DefineComponent<([\s\S]*?)>,\s*([A-Za-z0-9_]+)\s*>/m); const matchDefineOnly = matchWithSlots ? null : code.match(/import\("vue"\)\.DefineComponent<([\s\S]*?)>/m); const generic = matchWithSlots?.[1] || matchDefineOnly?.[1] || "any"; const head = code.split(/declare const __VLS_export/)[0] || ""; const extend = matchWithSlots ? ` & { new (): { $slots: ${matchWithSlots?.[2]} } }` : ""; code = [ `${head}`, `export default {} as (import("vue").DefineComponent<${generic}>${extend});` ].join("\n"); } return { component, code }; } ], checkerOptions: { forceUseTs: true, schema: { ignore: [ "NuxtComponentMetaNames", // avoid loop "RouteLocationRaw", // vue router "RouteLocationPathRaw", // vue router "RouteLocationNamedRaw", // vue router (_, type) => { const symbol = type?.symbol || type?.aliasSymbol; const declarations = symbol?.declarations || []; for (const decl of declarations) { const fileName = decl?.getSourceFile?.()?.fileName; if (!fileName) { continue; } if (fileName.includes("/node_modules/typescript/")) { return true; } } } ] } }, globalsOnly: false }), async setup(options, nuxt) { const resolver = createResolver(import.meta.url); const isComponentIncluded = (component) => { if (!options?.globalsOnly) { return true; } if (component.global) { return true; } return (options.include || []).find((excludeRule) => { switch (typeof excludeRule) { case "string": return component.pascalName === excludeRule || component.filePath.includes(excludeRule); case "object": return excludeRule instanceof RegExp ? excludeRule.test(component.filePath) : false; case "function": return excludeRule(component); default: return false; } }); }; let transformers = options?.transformers || []; transformers = await nuxt.callHook("component-meta:transformers", transformers) || transformers; let parser; const parserOptions = { ...options, components: [], metaSources: {}, transformers, overrides: options.overrides || {}, beforeWrite: async (schema) => { return await nuxt.callHook("component-meta:schema", schema) || schema; } }; let componentDirs = [...options?.componentDirs || []]; let components = []; let metaSources = {}; const uiTemplatesPath = await tryResolveModule("@nuxt/ui-templates", import.meta.url); nuxt.hook("components:dirs", (dirs) => { componentDirs = [ ...componentDirs, ...dirs, { path: nuxt.options.appDir } ]; if (uiTemplatesPath) { componentDirs.push({ path: uiTemplatesPath.replace("/index.mjs", "/templates") }); } parserOptions.componentDirs = componentDirs; }); nuxt.hook("components:extend", (_components) => { _components.forEach((c) => { if (c.global) { parserOptions.componentDirs.push(c.filePath); } }); }); nuxt.hook("components:extend", async (_components) => { components = _components.filter(isComponentIncluded); metaSources = await loadExternalSources(options.metaSources); parserOptions.components = components; parserOptions.metaSources = metaSources; await nuxt.callHook("component-meta:extend", parserOptions); parser = useComponentMetaParser(parserOptions); await Promise.all([ parser.init(), parser.stubOutput() ]); }); addImportsDir(resolver.resolve("./runtime/composables")); addTemplate({ filename: "component-meta.d.ts", getContents: () => [ "import type { ComponentData } from 'nuxt-component-meta'", `export type NuxtComponentMetaNames = ${[...components, ...Object.values(metaSources)].map((c) => `'${c.pascalName}'`).join(" | ")}`, "export type NuxtComponentMeta = Record<NuxtComponentMetaNames, ComponentData>", "declare const components: NuxtComponentMeta", "export { components as default, components }" ].join("\n"), write: true }); nuxt.hook("vite:extend", (vite) => { vite.config.plugins = vite.config.plugins || []; vite.config.plugins.push(metaPlugin.vite({ parser, parserOptions })); }); nuxt.options.alias = nuxt.options.alias || {}; nuxt.options.alias["#nuxt-component-meta"] = join(nuxt.options.buildDir, "component-meta.mjs"); nuxt.options.alias["#nuxt-component-meta/types"] = join(nuxt.options.buildDir, "component-meta.d.ts"); nuxt.hook("prepare:types", ({ references }) => { references.push({ path: join(nuxt.options.buildDir, "component-meta.d.ts") }); }); nuxt.hook("nitro:config", (nitroConfig) => { nitroConfig.handlers = nitroConfig.handlers || []; nitroConfig.virtual = nitroConfig.virtual || {}; nitroConfig.virtual["#nuxt-component-meta/nitro"] = () => readFileSync(join(nuxt.options.buildDir, "/component-meta.mjs"), "utf-8"); }); addServerHandler({ method: "get", route: "/api/component-meta", handler: resolver.resolve("./runtime/server/api/component-meta.get") }); addServerHandler({ method: "get", route: "/api/component-meta.json", handler: resolver.resolve("./runtime/server/api/component-meta.json.get") }); addServerHandler({ method: "get", route: "/api/component-meta/:component?", handler: resolver.resolve("./runtime/server/api/component-meta-component.get") }); } }); export { module as default };