@stencil/vue-output-target
Version:
Vue output target for @stencil/core components.
299 lines (293 loc) • 13.6 kB
JavaScript
import path from 'node:path';
import fs from 'node:fs/promises';
import path$1 from 'path';
const dashToPascalCase = (str) => str
.toLowerCase()
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join('');
function sortBy(array, prop) {
return array.slice().sort((a, b) => {
const nameA = prop(a);
const nameB = prop(b);
if (nameA < nameB)
return -1;
if (nameA > nameB)
return 1;
return 0;
});
}
function normalizePath(str) {
// Convert Windows backslash paths to slash paths: foo\\bar ➔ foo/bar
// https://github.com/sindresorhus/slash MIT
// By Sindre Sorhus
if (typeof str !== 'string') {
throw new Error(`invalid path to normalize`);
}
str = str.trim();
if (EXTENDED_PATH_REGEX.test(str) || NON_ASCII_REGEX.test(str)) {
return str;
}
str = str.replace(SLASH_REGEX, '/');
// always remove the trailing /
// this makes our file cache look ups consistent
if (str.charAt(str.length - 1) === '/') {
const colonIndex = str.indexOf(':');
if (colonIndex > -1) {
if (colonIndex < str.length - 2) {
str = str.substring(0, str.length - 1);
}
}
else if (str.length > 1) {
str = str.substring(0, str.length - 1);
}
}
return str;
}
function relativeImport(pathFrom, pathTo, ext) {
let relativePath = path.relative(path.dirname(pathFrom), path.dirname(pathTo));
if (relativePath === '') {
relativePath = '.';
}
else if (relativePath[0] !== '.') {
relativePath = './' + relativePath;
}
return normalizePath(`${relativePath}/${path.basename(pathTo, ext)}`);
}
async function readPackageJson(rootDir) {
const pkgJsonPath = path.join(rootDir, 'package.json');
let pkgJson;
try {
pkgJson = await fs.readFile(pkgJsonPath, 'utf8');
}
catch (e) {
throw new Error(`Missing "package.json" file for distribution: ${pkgJsonPath}`);
}
let pkgData;
try {
pkgData = JSON.parse(pkgJson);
}
catch (e) {
throw new Error(`Error parsing package.json: ${pkgJsonPath}, ${e}`);
}
return pkgData;
}
const EXTENDED_PATH_REGEX = /^\\\\\?\\/;
const NON_ASCII_REGEX = /[^\x00-\x80]+/;
const SLASH_REGEX = /\\/g;
const createComponentDefinition = (importTypes, outputTarget) => (cmpMeta) => {
const tagNameAsPascal = dashToPascalCase(cmpMeta.tagName);
const importAs = outputTarget.includeImportCustomElements ? 'define' + tagNameAsPascal : 'undefined';
let props = [];
let emits = [];
let propMap = {};
if (Array.isArray(cmpMeta.properties) && cmpMeta.properties.length > 0) {
props = cmpMeta.properties.map((prop) => `'${prop.name}'`);
cmpMeta.properties.forEach((prop) => {
if (['boolean', 'string', 'number'].includes(prop.type)) {
propMap[prop.name] = [prop.type[0].toUpperCase() + prop.type.slice(1), prop.attribute];
}
});
}
if (Array.isArray(cmpMeta.events) && cmpMeta.events.length > 0) {
const events = (emits = cmpMeta.events.map((event) => `'${event.name}'`));
props = [...props, ...events];
emits = events;
cmpMeta.events.forEach((event) => {
const handlerName = `on${event.name[0].toUpperCase() + event.name.slice(1)}`;
propMap[handlerName] = ['Function', undefined];
});
}
const componentType = `${importTypes}.${tagNameAsPascal}`;
const findModel = outputTarget.componentModels?.find((config) => config.elements.includes(cmpMeta.tagName));
const modelType = findModel !== undefined ? `, ${componentType}["${findModel.targetAttr}"]` : '';
const supportSSR = typeof outputTarget.hydrateModule === 'string';
const ssrTernary = supportSSR ? ' globalThis.window ? ' : ' ';
const ssrCondition = supportSSR
? ` : defineStencilSSRComponent<${componentType}${modelType}>({
tagName: '${cmpMeta.tagName}',
hydrateModule: import('${outputTarget.hydrateModule}'),
props: {
${Object.entries(propMap)
.map(([key, [type, attr]]) => (attr ? `'${key}': [${type}, "${attr}"]` : `'${key}': [${type}]`))
.join(',\n ')}
}
})`
: '';
let templateString = `
export const ${tagNameAsPascal}: StencilVueComponent<${componentType}${modelType}> = /*@__PURE__*/${ssrTernary}defineContainer<${componentType}${modelType}>('${cmpMeta.tagName}', ${importAs}`;
if (props.length > 0) {
templateString += `, [
${props.length > 0 ? props.join(',\n ') : ''}
]`;
/**
* If there are no props,
* but v-model is still used,
* make sure we pass in an empty array
* otherwise all of the defineContainer properties
* will be off by one space.
* Note: If you are using v-model then
* the props array should never be empty
* as there must be a prop for v-model to update,
* but this check is there so builds do not crash.
*/
}
else if (emits.length > 0) {
templateString += `, []`;
}
if (emits.length > 0) {
templateString += `, [
${emits.length > 0 ? emits.join(',\n ') : ''}
]`;
/**
* If there are no Emits,
* but v-model is still used,
* make sure we pass in an empty array
* otherwise all of the defineContainer properties
* will be off by one space.
* Note: If you are using v-model then
* the props array should never be empty
* as there must be a prop for v-model to update,
* but this check is there so builds do not crash.
*/
}
else if (findModel) {
templateString += `, []`;
}
if (findModel) {
const targetProp = findModel.targetAttr;
/**
* If developer is trying to bind v-model support to a component's
* prop, but that prop was not defined, warn them of this otherwise
* v-model will not work as expected.
*/
if (!props.includes(`'${targetProp}'`)) {
console.warn(`Your '${cmpMeta.tagName}' component is configured to have v-model support bound to '${targetProp}', but '${targetProp}' is not defined as a property on the component. v-model integration may not work as expected.`);
}
templateString += `,\n`;
templateString += `'${targetProp}', '${findModel.event}'`;
}
templateString += `)${ssrCondition};\n`;
return templateString;
};
async function vueProxyOutput(config, compilerCtx, outputTarget, components) {
const filteredComponents = getFilteredComponents(outputTarget.excludeComponents, components);
const rootDir = config.rootDir;
const pkgData = await readPackageJson(rootDir);
const finalText = generateProxies(config, filteredComponents, pkgData, outputTarget, rootDir);
await compilerCtx.fs.writeFile(outputTarget.proxiesFile, finalText);
}
function getFilteredComponents(excludeComponents = [], cmps) {
return sortBy(cmps, (cmp) => cmp.tagName).filter((c) => !excludeComponents.includes(c.tagName) && !c.internal);
}
function generateProxies(config, components, pkgData, outputTarget, rootDir) {
const distTypesDir = path$1.dirname(pkgData.types);
const dtsFilePath = path$1.join(rootDir, distTypesDir, GENERATED_DTS);
const componentsTypeFile = relativeImport(outputTarget.proxiesFile, dtsFilePath, '.d.ts');
const pathToCorePackageLoader = getPathToCorePackageLoader(config, outputTarget);
const importKeys = [
'defineContainer',
typeof outputTarget.hydrateModule === 'string' ? 'defineStencilSSRComponent' : undefined,
'type StencilVueComponent',
].filter(Boolean);
const imports = `/* eslint-disable */
/* tslint:disable */
/* auto-generated vue proxies */
import { ${importKeys.join(', ')} } from '@stencil/vue-output-target/runtime';\n`;
const generateTypeImports = () => {
if (outputTarget.componentCorePackage !== undefined) {
const dirPath = outputTarget.includeImportCustomElements && outputTarget.customElementsDir
? `/${outputTarget.customElementsDir}`
: '';
return `import type { ${IMPORT_TYPES} } from '${normalizePath(outputTarget.componentCorePackage)}${dirPath}';\n`;
}
return `import type { ${IMPORT_TYPES} } from '${normalizePath(componentsTypeFile)}';\n`;
};
const typeImports = generateTypeImports();
let sourceImports = '';
let registerCustomElements = '';
if (outputTarget.includeImportCustomElements && outputTarget.componentCorePackage !== undefined) {
const cmpImports = components.map((component) => {
const pascalImport = dashToPascalCase(component.tagName);
return `import { defineCustomElement as define${pascalImport} } from '${normalizePath(outputTarget.componentCorePackage)}/${outputTarget.customElementsDir || 'components'}/${component.tagName}.js';`;
});
sourceImports = cmpImports.join('\n');
}
else if (outputTarget.includePolyfills && outputTarget.includeDefineCustomElements) {
sourceImports = `import { ${APPLY_POLYFILLS}, ${REGISTER_CUSTOM_ELEMENTS} } from '${pathToCorePackageLoader}';\n`;
registerCustomElements = `${APPLY_POLYFILLS}().then(() => ${REGISTER_CUSTOM_ELEMENTS}());`;
}
else if (!outputTarget.includePolyfills && outputTarget.includeDefineCustomElements) {
sourceImports = `import { ${REGISTER_CUSTOM_ELEMENTS} } from '${pathToCorePackageLoader}';\n`;
registerCustomElements = `${REGISTER_CUSTOM_ELEMENTS}();`;
}
const final = [
imports,
typeImports,
sourceImports,
registerCustomElements,
components.map(createComponentDefinition(IMPORT_TYPES, outputTarget)).join('\n'),
];
return final.join('\n') + '\n';
}
function getPathToCorePackageLoader(config, outputTarget) {
const basePkg = outputTarget.componentCorePackage || '';
const distOutputTarget = config.outputTargets?.find((o) => o.type === 'dist');
const distAbsEsmLoaderPath = distOutputTarget?.esmLoaderPath && path$1.isAbsolute(distOutputTarget.esmLoaderPath)
? distOutputTarget.esmLoaderPath
: null;
const distRelEsmLoaderPath = config.rootDir && distAbsEsmLoaderPath ? path$1.relative(config.rootDir, distAbsEsmLoaderPath) : null;
const loaderDir = outputTarget.loaderDir || distRelEsmLoaderPath || DEFAULT_LOADER_DIR;
return normalizePath(path$1.join(basePkg, loaderDir));
}
const GENERATED_DTS = 'components.d.ts';
const IMPORT_TYPES = 'JSX';
const REGISTER_CUSTOM_ELEMENTS = 'defineCustomElements';
const APPLY_POLYFILLS = 'applyPolyfills';
const DEFAULT_LOADER_DIR = '/dist/loader';
const vueOutputTarget = (outputTarget) => ({
type: 'custom',
name: 'vue-library',
validate(config) {
return normalizeOutputTarget(config, outputTarget);
},
async generator(config, compilerCtx, buildCtx) {
const timespan = buildCtx.createTimeSpan(`generate vue started`, true);
await vueProxyOutput(config, compilerCtx, outputTarget, buildCtx.components);
timespan.finish(`generate vue finished`);
},
});
function normalizeOutputTarget(config, outputTarget) {
const results = {
...outputTarget,
excludeComponents: outputTarget.excludeComponents || [],
componentModels: outputTarget.componentModels || [],
includePolyfills: outputTarget.includePolyfills ?? true,
includeDefineCustomElements: outputTarget.includeDefineCustomElements ?? true,
};
if (config.rootDir == null) {
throw new Error('rootDir is not set and it should be set by stencil itself');
}
if (outputTarget.proxiesFile == null) {
throw new Error('proxiesFile is required');
}
if (outputTarget.includeDefineCustomElements && outputTarget.includeImportCustomElements) {
throw new Error('includeDefineCustomElements cannot be used at the same time as includeImportCustomElements since includeDefineCustomElements is used for lazy loading components. Set `includeDefineCustomElements: false` in your Vue output target config to resolve this.');
}
if (typeof outputTarget.includeDefineCustomElements === 'boolean' &&
!outputTarget.includeDefineCustomElements &&
typeof outputTarget.includeImportCustomElements === 'boolean' &&
!outputTarget.includeImportCustomElements) {
throw new Error('`includeDefineCustomElements` and `includeImportCustomElements` cannot both be set to `false`!\n\n' +
'Enable one of the options depending whether you would like to lazy load the Stencil components (includeDefineCustomElements: true) or ' +
'include all component code within your application bundle and have the bundler lazy load the chunks (includeImportCustomElements: true).');
}
if (outputTarget.includeImportCustomElements && outputTarget.includePolyfills) {
throw new Error('includePolyfills cannot be used at the same time as includeImportCustomElements. Set `includePolyfills: false` in your Vue output target config to resolve this.');
}
if (outputTarget.directivesProxyFile && !path$1.isAbsolute(outputTarget.directivesProxyFile)) {
results.proxiesFile = normalizePath(path$1.join(config.rootDir, outputTarget.proxiesFile));
}
return results;
}
export { vueOutputTarget };