diff --git a/packages/gem-analyzer/src/index.ts b/packages/gem-analyzer/src/index.ts index 99dd4c86..9dbe2161 100644 --- a/packages/gem-analyzer/src/index.ts +++ b/packages/gem-analyzer/src/index.ts @@ -1,9 +1,11 @@ import type { SourceFile, ClassDeclaration, Project } from 'ts-morph'; import { camelToKebabCase } from '@mantou/gem/lib/utils'; -import { getJsDoc } from './lib/utils'; +import { getJsDoc, getTypeText, isGetter, isSetter } from './lib/utils'; interface StaticProperty { + getter?: boolean; + setter?: boolean; name: string; deprecated?: boolean; slot?: string; @@ -20,6 +22,8 @@ interface StaticMethod { } interface Property { + getter?: boolean; + setter?: boolean; name: string; reactive: boolean; deprecated?: boolean; @@ -50,6 +54,7 @@ interface ConstructorParam { } export interface ElementDetail { + relativePath?: string; shadow: boolean; name: string; constructorName: string; @@ -68,6 +73,16 @@ export interface ElementDetail { extend?: ElementDetail; } +export const getChain = (detail: ElementDetail) => { + let root = detail; + const result: ElementDetail[] = [root]; + while (root.extend) { + root = root.extend; + result.push(root); + } + return result; +}; + const shadowDecoratorName = ['shadow']; const elementDecoratorName = ['customElement']; const attrDecoratorName = ['attribute', 'boolattribute', 'numattribute']; @@ -81,6 +96,7 @@ const globalEventDecoratorName = ['globalemitter']; const lifecyclePopsOrMethods = ['state', 'willMount', 'render', 'mounted', 'shouldUpdate', 'updated', 'unmounted']; async function getExtendsClassDetail(className: string, sourceFile: SourceFile, project?: Project) { + if (className === 'GemElement') return; const currentFile = sourceFile.getFilePath(); const isAbsFilePath = currentFile.startsWith('/'); if (!isAbsFilePath || !project) return; @@ -102,7 +118,10 @@ async function getExtendsClassDetail(className: string, sourceFile: SourceFile, const text = await fileSystem.readFile(pathname); const file = project.createSourceFile(pathname, text, { overwrite: true }); const classDeclaration = file.getClass(className); - return classDeclaration && parseElement(classDeclaration, file, project); + if (!classDeclaration) return; + const detail = await parseElement(classDeclaration, file, project); + detail.relativePath = depPath; + return detail; } catch { return; } @@ -151,7 +170,7 @@ export const parseElement = async (declaration: ClassDeclaration, file: SourceFi }); detail.constructorParams = constructor.getParameters().map((param) => ({ name: param.getName(), - type: param.getType().getText(), + type: getTypeText(param), description: params[param.getName()], })); } @@ -164,6 +183,8 @@ export const parseElement = async (declaration: ClassDeclaration, file: SourceFi const prop: StaticProperty = { name: staticPropName, type: staticPropDeclaration.getType().getText(), + getter: isGetter(staticPropDeclaration), + setter: isSetter(staticPropDeclaration), ...getJsDoc(staticPropDeclaration), }; @@ -208,7 +229,9 @@ export const parseElement = async (declaration: ClassDeclaration, file: SourceFi const prop: Property = { name: propName, reactive: false, - type: propDeclaration.getType().getText(), + type: getTypeText(propDeclaration), + getter: isGetter(propDeclaration), + setter: isSetter(propDeclaration), ...getJsDoc(propDeclaration), }; detail.properties.push(prop); @@ -250,7 +273,7 @@ export const parseElement = async (declaration: ClassDeclaration, file: SourceFi if (lifecyclePopsOrMethods.includes(methodName)) continue; const method: Method = { name: methodName, - type: methodDeclaration.getType().getText(), + type: getTypeText(methodDeclaration), ...getJsDoc(methodDeclaration), }; detail.methods.push(method); @@ -267,6 +290,13 @@ export const parseElement = async (declaration: ClassDeclaration, file: SourceFi } } } + + const elementDecorators = declaration.getDecorators(); + const shadowDeclaration = elementDecorators.find((decorator) => shadowDecoratorName.includes(decorator.getName())); + if (shadowDeclaration) { + detail.shadow = true; + } + return detail; }; @@ -278,7 +308,6 @@ export const getElements = async (file: SourceFile, project?: Project) => { const elementDeclaration = elementDecorators.find((decorator) => elementDecoratorName.includes(decorator.getName()), ); - const shadowDeclaration = elementDecorators.find((decorator) => shadowDecoratorName.includes(decorator.getName())); const elementTag = elementDeclaration ?.getCallExpression()! @@ -295,7 +324,6 @@ export const getElements = async (file: SourceFile, project?: Project) => { const detail: ElementDetail = { ...(await parseElement(declaration, file, project)), name: elementTag, - shadow: !!shadowDeclaration, }; if (!detail.constructorName.startsWith('_')) { result.push(detail); diff --git a/packages/gem-analyzer/src/lib/utils.ts b/packages/gem-analyzer/src/lib/utils.ts index d196f7fa..25d73ebe 100644 --- a/packages/gem-analyzer/src/lib/utils.ts +++ b/packages/gem-analyzer/src/lib/utils.ts @@ -1,4 +1,9 @@ -import { ClassInstancePropertyTypes, MethodDeclaration, ExportedDeclarations } from 'ts-morph'; +import { + ClassInstancePropertyTypes, + MethodDeclaration, + ExportedDeclarations, + ClassStaticPropertyTypes, +} from 'ts-morph'; export function getJsDoc(declaration: ClassInstancePropertyTypes | MethodDeclaration | ExportedDeclarations) { if ('getJsDocs' in declaration) { @@ -14,3 +19,23 @@ export function getJsDoc(declaration: ClassInstancePropertyTypes | MethodDeclara }; } } + +export function getTypeText(declaration: ClassInstancePropertyTypes | MethodDeclaration) { + const structure = declaration.getStructure(); + return ( + ('type' in structure && typeof structure.type === 'string' && structure.type) || declaration.getType().getText() + ); +} + +export function isGetter( + declaration: ClassInstancePropertyTypes | ClassStaticPropertyTypes, + kind: 'get' | 'set' = 'get', +) { + const first = declaration.getFirstChild(); + const firstText = first?.getText(); + return (firstText === 'static' ? first?.getNextSibling()?.getText() : firstText) === kind; +} + +export function isSetter(declaration: ClassInstancePropertyTypes | ClassStaticPropertyTypes) { + return isGetter(declaration, 'set'); +} diff --git a/packages/gem-book/src/plugins/api.ts b/packages/gem-book/src/plugins/api.ts index 6f76bbcd..05988d2c 100644 --- a/packages/gem-book/src/plugins/api.ts +++ b/packages/gem-book/src/plugins/api.ts @@ -90,9 +90,13 @@ class _GbpApiElement extends GemBookPluginElement { return `${level} ${title} {#${hash}}\n\n`; }; - #renderCode = (s = '', deprecated?: boolean) => { + #renderCode = (s = '', deprecated?: boolean, prefix?: string) => { if (!s) return ''; - const code = `\`${s.replace(/\|/g, '\\|').replace(/\n/g, ' ')}\``; + const code = `\`${[prefix, s] + .filter((e) => e) + .join(' ') + .replace(/\|/g, '\\|') + .replace(/\n/g, ' ')}\``; return deprecated ? `~~${code}~~` : code; }; @@ -156,7 +160,8 @@ class _GbpApiElement extends GemBookPluginElement { staticProperties, ['Property', 'Type'].concat(constructorParams.some((e) => e.description) ? 'Description' : []), [ - ({ name, deprecated }) => this.#renderCode(name, deprecated), + ({ name, deprecated, getter, setter }) => + this.#renderCode(name, deprecated, getter ? 'get' : setter ? 'set' : ''), ({ type }) => this.#renderCode(type), ({ description = '' }) => description.replaceAll('\n', '
'), ], @@ -182,8 +187,9 @@ class _GbpApiElement extends GemBookPluginElement { innerWidth > 600 && constructorParams.some((e) => e.description) ? 'Description' : [], ), [ - ({ name, attribute: attr, deprecated }) => - this.#renderCode(name, deprecated) + (attr ? `(${this.#renderCode(attr, deprecated)})` : ''), + ({ name, attribute: attr, deprecated, setter, getter }) => + this.#renderCode(name, deprecated, getter ? 'get' : setter ? 'set' : '') + + (attr ? `(${this.#renderCode(attr, deprecated)})` : ''), ({ reactive }) => (reactive ? 'Yes' : ''), ({ type }) => this.#renderCode(type), ({ description = '' }) => description.replaceAll('\n', '
'), diff --git a/packages/gem-port/src/common.ts b/packages/gem-port/src/common.ts index 75e8d405..3c8e92c2 100644 --- a/packages/gem-port/src/common.ts +++ b/packages/gem-port/src/common.ts @@ -1,8 +1,8 @@ import path from 'path'; -import { readdirSync, readFileSync, statSync } from 'fs'; +import { readdirSync, readFileSync, statSync, promises } from 'fs'; -import { ElementDetail, getElements } from 'gem-analyzer'; -import { Project } from 'ts-morph'; +import { ElementDetail, getChain, getElements } from 'gem-analyzer'; +import { Project, ts as morphTs } from 'ts-morph'; import * as ts from 'typescript'; const dev = process.env.MODE === 'dev'; @@ -26,13 +26,18 @@ export function getElementPathList(elementsDir: string) { return paths; } -const elementCache: Record = {}; -const project = new Project({ useInMemoryFileSystem: true }); +const elementCache: Record = {}; +const project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { target: morphTs.ScriptTarget.ESNext }, +}); +project.getFileSystem().readFile = (filePath) => promises.readFile(filePath, 'utf8'); export async function getFileElements(elementFilePath: string) { if (!elementCache[elementFilePath]) { const text = readFileSync(elementFilePath, { encoding: 'utf-8' }); - const file = project.createSourceFile(elementFilePath, text); - elementCache[elementFilePath] = await getElements(file); + const file = project.getSourceFile(elementFilePath) || project.createSourceFile(elementFilePath, text); + const details = await getElements(file, project); + elementCache[elementFilePath] = details.map((detail) => [detail, getChain(detail)] as const); } return elementCache[elementFilePath]; } diff --git a/packages/gem-port/src/index.ts b/packages/gem-port/src/index.ts index 56d3732a..bc363b60 100644 --- a/packages/gem-port/src/index.ts +++ b/packages/gem-port/src/index.ts @@ -15,6 +15,8 @@ const cliOptions = { svelteNs: '', }; +const timer = setTimeout(() => program.outputHelp()); + program .name(name) .description(description) @@ -23,12 +25,11 @@ program .option('--svelte-ns ', `specify svelte element namespace`, (ns: string) => (cliOptions.svelteNs = ns)) .arguments('') .action(async (dir: string) => { - await Promise.all([ - compileReact(dir, path.resolve(cliOptions.outDir, 'react')), - generateVue(dir, path.resolve(cliOptions.outDir, 'vue')), - compileSvelte(dir, path.resolve(cliOptions.outDir, 'svelte'), cliOptions.svelteNs), - ]); + clearTimeout(timer); + await compileReact(dir, path.resolve(cliOptions.outDir, 'react')); + await generateVue(dir, path.resolve(cliOptions.outDir, 'vue')); + await compileSvelte(dir, path.resolve(cliOptions.outDir, 'svelte'), cliOptions.svelteNs); process.exit(0); }); -program.parse(process.argv).outputHelp(); +program.parse(process.argv); diff --git a/packages/gem-port/src/react.ts b/packages/gem-port/src/react.ts index 87dd197e..825e56f6 100644 --- a/packages/gem-port/src/react.ts +++ b/packages/gem-port/src/react.ts @@ -10,11 +10,12 @@ import { async function createReactSourceFile(elementFilePath: string, outDir: string) { const elementDetailList = await getFileElements(elementFilePath); return Object.fromEntries( - elementDetailList.map(({ name: tag, constructorName, properties, methods }) => { + elementDetailList.map(([{ name: tag, constructorName, properties, methods }, chain]) => { const componentName = getComponentName(tag); const componentPropsName = `${componentName}Props`; - const componentMethodsName = `${componentName}Methods`; + const componentExposeName = `${componentName}Expose`; const relativePath = getRelativePath(elementFilePath, outDir); + const getters = properties.filter((e) => e.getter); return [ componentName + '.tsx', ` @@ -25,18 +26,26 @@ async function createReactSourceFile(elementFilePath: string, outDir: string) { export type ${componentPropsName} = HTMLAttributes & RefAttributes<${constructorName}> & { ${properties - .map(({ name, reactive, event, deprecated }) => + .map(({ name, getter, event, deprecated }) => event ? [ getJsDocDescName(`'on${event}'`, deprecated), `(event: CustomEvent[0]>) => void`, ].join('?:') - : reactive + : !getter ? [getJsDocDescName(name, deprecated), `${constructorName}['${name}']`].join('?:') : '', ) .join(';\n')} }; + + export type ${componentExposeName} = { + ${[...methods, ...getters] + .map(({ name, deprecated }) => + [getJsDocDescName(name, deprecated), `${constructorName}['${name}']`].join(': '), + ) + .join(';\n')} + } declare global { namespace JSX { @@ -45,16 +54,8 @@ async function createReactSourceFile(elementFilePath: string, outDir: string) { } } } - - export type ${componentMethodsName} = { - ${methods - .map(({ name, deprecated }) => - [getJsDocDescName(name, deprecated), `${constructorName}['${name}']`].join(': '), - ) - .join(';\n')} - } - export const ${componentName}: ForwardRefExoticComponent & RefAttributes<${componentMethodsName}>> = forwardRef<${componentMethodsName}, ${componentPropsName}>(function (props, ref): JSX.Element { + export const ${componentName}: ForwardRefExoticComponent & RefAttributes<${componentExposeName}>> = forwardRef<${componentExposeName}, ${componentPropsName}>(function (props, ref): JSX.Element { const elementRef = useRef<${constructorName}>(null); useImperativeHandle(ref, () => { return { @@ -62,11 +63,20 @@ async function createReactSourceFile(elementFilePath: string, outDir: string) { .map( ({ name }) => ` ${name}(...args) { - elementRef.current?.${name}(...args) - } + return elementRef.current!.${name}(...args) + }, `, ) - .join(',')} + .join('')} + ${getters + .map( + ({ name }) => ` + get ${name}() { + return elementRef.current!.${name} + }, + `, + ) + .join('')} }; }, []); diff --git a/packages/gem-port/src/svelte.ts b/packages/gem-port/src/svelte.ts index 24e4d31c..e24d03b2 100644 --- a/packages/gem-port/src/svelte.ts +++ b/packages/gem-port/src/svelte.ts @@ -17,7 +17,7 @@ export async function compileSvelte(elementsDir: string, outDir: string, ns = '' Object.assign( fileSystem, Object.fromEntries( - elementDetailList.map(({ name: tag, constructorName, properties }) => { + elementDetailList.map(([{ name: tag, constructorName, properties }]) => { const componentName = getComponentName(tag); const componentPropsName = `${componentName}Props`; const relativePath = getRelativePath(elementFilePath, outDir); @@ -31,13 +31,13 @@ export async function compileSvelte(elementsDir: string, outDir: string, ns = '' interface ${componentPropsName} extends HTMLAttributes { ${properties - .map(({ name, reactive, event, deprecated }) => + .map(({ name, getter, event, deprecated }) => event ? [ getJsDocDescName(`'on:${event}'`, deprecated), `(event: CustomEvent[0]>) => void`, ].join('?:') - : reactive + : !getter ? [getJsDocDescName(name, deprecated), `${constructorName}['${name}']`].join('?:') : '', ) diff --git a/packages/gem-port/src/vue.ts b/packages/gem-port/src/vue.ts index d611275b..127747b9 100644 --- a/packages/gem-port/src/vue.ts +++ b/packages/gem-port/src/vue.ts @@ -32,11 +32,12 @@ export async function generateVue(elementsDir: string, outDir: string) { const processFile = async (elementFilePath: string) => { const elements = await getFileElements(elementFilePath); - elements.forEach(({ name: tag, properties, constructorName, methods, events }) => { + elements.forEach(([{ name: tag, properties, constructorName, methods, events }]) => { const componentName = getComponentName(tag); - const componentMethodsName = `${componentName}Methods`; + const componentExposeName = `${componentName}Expose`; const relativePath = getRelativePath(elementFilePath, outDir); - const reactiveProps = properties.filter(({ reactive }) => !!reactive); + const settableProperties = properties.filter((e) => !e.getter && !e.event); + const getters = properties.filter((e) => e.getter); writeFileSync( resolve(outDir, componentName + '.vue'), ` @@ -46,19 +47,13 @@ export async function generateVue(elementsDir: string, outDir: string) { const elementRef = ref<${constructorName}>(); - ${ - reactiveProps.length - ? ` const props = defineProps<{ - ${reactiveProps + ${settableProperties .map(({ name, deprecated }) => [getJsDocDescName(name, deprecated), `${constructorName}['${name}']`].join('?: '), ) .join(',\n')} }>(); - ` - : '' - } const emit = defineEmits<{ ${properties @@ -72,30 +67,39 @@ export async function generateVue(elementsDir: string, outDir: string) { .join(',\n')} }>() - type ${componentMethodsName} = { - ${methods + type ${componentExposeName} = { + ${[...methods, ...getters] .map(({ name, deprecated }) => [getJsDocDescName(name, deprecated), `${constructorName}['${name}']`].join(': '), ) .join(';\n')} } - defineExpose<${componentMethodsName}>({ + defineExpose<${componentExposeName}>({ ${methods .map( ({ name }) => ` ${name}(...args) { - elementRef.value?.${name}(...args) - } + return elementRef.value!.${name}(...args) + }, + `, + ) + .join('')} + ${getters + .map( + ({ name }) => ` + get ${name}() { + return elementRef.value!.${name} + }, `, ) - .join(',')} + .join('')} }) // prop 可以用 :prop onMounted(() => { const element = elementRef.value!; - ${properties.map(({ name, reactive }) => (reactive ? `element.${name} = props.${name}` : '')).join(';\n')} + ${settableProperties.map(({ name }) => `element.${name} = props.${name}`).join(';\n')} }) @@ -108,8 +112,8 @@ export async function generateVue(elementsDir: string, outDir: string) {