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
JavaScript
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 };