@lit-labs/gen-manifest 深度解析:为 Lit 组件生成 Custom Elements Manifest 的完整指南
【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit
导读
@lit-labs/gen-manifest是 Lit 官方实验性工具链(@lit-labs/*)中负责为组件库生成Custom Elements Manifest(自定义元素清单,简称 CEM)的代码生成器。它把一个 TypeScript 包的静态分析结果(由@lit-labs/analyzer产出)转换成为社区标准的custom-elements.json清单文件,从而让 IDE 智能提示、文档站点、代码生成器等下游工具可以自动理解你的 Lit 组件对外暴露的 API。读完本文,你将掌握@lit-labs/gen-manifest的两种使用方式(编程 API 与 CLI 命令)、完整的 manifest 输出结构,以及从 TypeScript 源码到标准 JSON 清单的逐级转换原理。
什么是 Custom Elements Manifest,它解决什么问题
Custom Elements Manifest 是 webcomponents 社区提出的一种 JSON 格式规范(本仓库实现中遵循其 schema1.0.0),用一个名为custom-elements.json的机器可读文件描述一个包中所有自定义元素及其相关模块的公共 API。它解决的核心痛点是:自定义元素的知识只存在于源码的 JSDoc 与 TypeScript 类型中,缺少一种标准化的、可供工具消费的元数据格式。
有了 manifest 之后,组件库作者一次生成、多方受益:IDE 可以根据清单提供属性补全与文档悬停;文档站点可以直接渲染 API 参考;包装器生成器可以据此为其他框架生成绑定代码。这一点与本仓库中 gen-wrapper-react、gen-wrapper-vue 等包的目标一脉相承——@lit-labs/gen-manifest正是这套“从 Lit 组件出发,生成标准元数据”链路中的关键一环。
快速上手:两种使用方式
官方 README 将该库定位为 “Utility library for generating a Custom Elements Manifest for Lit components”,并指出命令行入口在@lit-labs/cli。因此使用方式分为编程 API 与 CLI 两种。
方式一:编程方式调用(Library API)
在代码中直接引入生成函数,把分析器输出的Package模型交给generateManifest,即可得到一个文件树对象:
import {createPackageAnalyzer} from '@lit-labs/analyzer/package-analyzer.js'; import {generateManifest} from '@lit-labs/gen-manifest'; import {writeFileTree} from '@lit-labs/gen-utils/lib/file-utils.js'; const analyzer = createPackageAnalyzer('/path/to/your/package'); const pkg = analyzer.getPackage(); const fileTree = await generateManifest(pkg); // { 'custom-elements.json': '{...}' } await writeFileTree('/path/to/output', fileTree);其中:
createPackageAnalyzer来自依赖@lit-labs/analyzer(见 package.json),负责读取tsconfig.json并静态分析整个包;generateManifest是 src/index.ts 导出的核心函数,返回一个以custom-elements.json为键名的FileTree;writeFileTree来自@lit-labs/gen-utils,将文件树递归写入磁盘(详见下文“FileTree 抽象”)。
FileTree是生成器之间传递的统一数据结构,定义在 packages/labs/gen-utils/src/lib/file-utils.ts:
export interface FileTree { [path: string]: string | FileTree; }键名既可以是文件名(值为文件内容字符串),也可以是子目录名(值为嵌套的FileTree);文件名甚至可以包含/,写入时会自动创建中间目录。
方式二:CLI 命令(推荐)
@lit-labs/gen-manifest本身不提供命令行,而是通过@lit-labs/cli的lit labs gen子命令驱动(详见 cli README):
lit labs gen --manifest--manifest标志用于开启 manifest 生成。结合 labs.ts 中的选项定义,labs gen完整参数如下:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
--package | 可多次指定 | ./ | 要分析的包目录;对 TypeScript 项目,若目录下无tsconfig.json,也可直接指定某个tsconfig.json路径 |
--framework | 可多次指定 | 无 | 同时生成的框架包装器(react、vue),与 manifest 生成互不冲突 |
--manifest | 布尔 | false | 是否生成custom-elements.jsonmanifest |
--out | 字符串 | ./gen | 输出目录 |
--exclude | 可多次指定 | [] | 从分析中排除的源文件 glob |
例如同时生成 manifest 与 React 包装器:
lit labs gen --manifest --framework=react --package=packages/my-elements --out=./genCLI 的底层执行逻辑在 packages/labs/cli/src/lib/generate/generate.ts 中,其流程为:
- 对每个
--package路径执行path.resolve归一化,并用createPackageAnalyzer(root, {exclude})创建分析器; - 校验
package.json必须包含name字段(manifest 的类型引用解析依赖包名); - 收集生成器引用:
--manifest对应@lit-labs/gen-manifest,--framework对应各框架生成器; - 先并行尝试
import()所有生成器,若未安装则逐个询问安装(resolveCommandAndMaybeInstallNeededDeps); - 对每个生成器调用其
generate(options, console)拿到FileTree,再用writeFileTree写入--out目录; - 打印分析器收集到的 TypeScript 诊断信息,并将
Promise.allSettled捕获到的生成错误汇总抛出。
可以看到,manifest 生成器与 React/Vue 包装器生成器通过同一套“生成器引用”机制被 CLI 动态加载,这正是 src/index.ts 中导出getCommand()的原因——它把该包包装成 CLI 可解析的生成器命令:
export const getCommand = () => { return { name: 'manifest', description: 'Generate custom-elements.json manifest.', kind: 'resolved', async generate(options: {package: Package}): Promise<FileTree> { return await generateManifest(options.package); }, }; };CLI 侧对应的引用定义见 generate.ts:installFrom: '@lit-labs/gen-manifest'、importSpecifier: '@lit-labs/gen-manifest/index.js'。
工作原理:从 TypeScript 源码到 CEM JSON 的三级管线
generateManifest只是入口,真正的转换发生在convertPackage→convertModule→convertDeclaration的三级调用链中。整个管线可以用下图概括:
TypeScript 源码 │ ▼ @lit-labs/analyzer(createPackageAnalyzer) Package 模型(modules / declarations / exports / 类型引用) │ ▼ convertPackage CEM 根对象:{ schemaVersion: '1.0.0', modules: [...] } │ ▼ convertModule(每个源码模块) { kind: 'javascript-module', path, declarations, exports } │ ▼ convertDeclaration(按声明类型分派) class / custom-element / mixin / function / variable │ ▼ JSON.stringify custom-elements.json第一级:包(Package)
convertPackage 只做两件事:写死schemaVersion: '1.0.0',并把分析结果中每个模块映射为cem.Module:
const convertPackage = (pkg: Package): cem.Package => { return { schemaVersion: '1.0.0', modules: [...pkg.modules.map(convertModule)], }; };schemaVersion是 CEM 规范的版本号,本实现固定输出1.0.0,消费者可以据此判断如何解析清单。
第二级:模块(Module)
convertModule 将一个 ES 模块转换为javascript-module条目:
{ kind: 'javascript-module', path: module.jsPath, // 编译后的 JS 路径,如 'element-a.js' description, summary, deprecated, // 取自 JSDoc,空值会被剔除 declarations: [...], // 模块内声明的类/函数/变量等 exports: [ ...module.exportNames.map(convertJavascriptExport), // 普通 JS 导出 ...module.getCustomElementExports().map(convertCustomElementExport), // 自定义元素注册 ], }其中导出被分为两类:
js导出:{kind: 'js', name, declaration: {name, package?, module?}},记录符号名及其真实定义位置(支持export {Foo, Baz}这类别名重导出,golden 中Baz的declaration.name即指向原符号Bar);custom-element-definition导出:{kind: 'custom-element-definition', name: tagname, declaration: {name}},记录@customElement('element-a')注册出的标签名与类的对应关系。
第三级:声明(Declaration)分派
convertDeclaration 依据分析器模型的运行时类型把每种声明映射为对应的 CEM 形态:
| 分析器模型 | CEM 输出 | 关键字段 |
|---|---|---|
LitElementDeclaration | custom-element | tagName、customElement: true、attributes、events、slots、cssParts、cssProperties |
ClassDeclaration | class | superclass、mixins、members |
MixinDeclaration | mixin | parameters、return |
FunctionDeclaration | function | parameters、return |
VariableDeclaration | variable | type |
| 其他 | 抛错 | Unknown declaration: ... |
遇到尚未支持的类型(如CustomElementMixinDeclaration),转换器会直接抛出Unknown declaration错误(源码中有对应的 TODO 注释),这保证了输出不会被静默降级成残缺数据。
生成的 manifest 长什么样:以 golden 文件为解剖样本
仓库在 goldens/test-element-a/custom-elements.json 保存了一份 1212 行的完整基准输出(golden 文件),其输入是 test-projects/test-element-a 下的测试组件包。下面以它为准,逐块解剖输出结构。
自定义元素声明(CustomElementDeclaration)
以 element-a.ts 为例,源文件中的 JSDoc 标注:
/** * This is a description of my element. It's pretty great. The description has * text that spans multiple lines. * * @summary My awesome element * @fires a-changed - An awesome event to fire * @slot default - The default slot * @slot stuff - A slot for stuff * @cssProperty --foreground-color - The foreground color * @cssProp --background-color The background color * @cssPart header The header * @cssPart footer - The footer */ @customElement('element-a') export class ElementA extends LitElement { static override styles = css`...`; @property() foo?: string; override render() { return html` <h1 part="header">${this.foo}</h1> <slot></slot> <slot name="stuff"></slot> <footer part="footer">Footer</footer> `; } }生成的声明片段如下:
{ "kind": "class", "name": "ElementA", "description": "This is a description of my element...", "summary": "My awesome element", "superclass": {"name": "LitElement", "package": "lit"}, "members": [ {"kind": "field", "name": "foo", "privacy": "public", "type": {"text": "string | undefined"}, "attribute": "foo"}, {"kind": "method", "name": "render", "privacy": "public", "return": {"type": {"text": "TemplateResult<1>", "references": [...]}}} ], "tagName": "element-a", "customElement": true, "attributes": [{"name": "foo", "type": {"text": "string"}, "fieldName": "foo"}], "events": [{"name": "a-changed", "type": {"text": "Event"}, "description": "An awesome event to fire"}], "slots": [{"name": "default", "description": "The default slot"}, {"name": "stuff", "description": "A slot for stuff"}], "cssParts": [{"name": "header", "description": "The header"}, {"name": "footer", "description": "The footer"}], "cssProperties": [ {"name": "--foreground-color", "description": "The foreground color"}, {"name": "--background-color", "description": "The background color"} ] }可见description/summary/slots/cssParts/cssProperties/events 全部来自 JSDoc 标签(@summary、@fires、@slot、@cssProperty/@cssProp、@cssPart),这正是组件库作者需要养成的文档习惯——写注释即产出元数据。
成员转换:reactive property 如何变成字段与属性
convertLitElementMembers 对 LitElement 的成员做了特殊处理:普通字段原样输出,而响应式属性(reactive property)会额外补充attribute与reflects字段。属性名解析规则(与 Lit 自身的默认规则一致):
attribute: false→ 不生成attribute字段(且该属性也不会出现在attributes数组);attribute: 'my-attr'(字符串)→ 使用指定的属性名;- 其他情况 → 属性名全小写(
camelCase→camelcase)。
以 element-props.ts 为例,它混合了@property({type: Number})、@property({type: Boolean, reflect: true})、@property({type: Array})、@property({type: Object, attribute: false})和@state(),生成的字段与属性为:
{ "members": [ {"kind": "field", "name": "aStr", "type": {"text": "string"}, "default": "'aStr'", "attribute": "astr"}, {"kind": "field", "name": "aNum", "type": {"text": "number"}, "default": "-1", "attribute": "anum"}, {"kind": "field", "name": "aBool", "type": {"text": "boolean"}, "default": "false", "attribute": "abool", "reflects": true}, {"kind": "field", "name": "aMyType", "type": {"text": "MyType", "references": [...]}, "default": "{...}", "attribute": "amytype"} ], "attributes": [ {"name": "astr", "type": {"text": "string"}, "fieldName": "aStr"}, {"name": "abool", "type": {"text": "boolean"}, "fieldName": "aBool"}, {"name": "astrarray", "type": {"text": "array"}, "fieldName": "aStrArray"}, {"name": "amytype", "type": {"text": "object"}, "fieldName": "aMyType"} ] }值得注意的细节:
attribute: false的aMyType仍出现在attributes数组中(golden 中可看到amytype条目)——等等,对照源码:convertReactivePropertiesToAttributes中property.attribute === false会continue跳过。golden 中aMyType之所以有amytype属性,是因为其声明写的是@property({type: Object, attribute: false})…… 实际上 golden 里aMyType的字段带attribute: "amytype",说明当前测试工程中该属性的attribute并未设置为false(golden 反映的是源码的实际状态)。结论是:只要attribute不是false,响应式属性就会同时出现在members(带attribute字段)和attributes数组(带fieldName回指)两处,这种冗余是有意设计的,方便按“字段”或按“HTML 属性”两个视角查询;- 类型文本映射:
convertReactivePropertiesToAttributes中typeOption?.toLowerCase() ?? 'string'把 Lit 的type: Number/Boolean/Array/Object选项映射为 CEM 的类型文本number/boolean/array/object(源码注释说明这对默认支持类型有效,其他类型会直接输出类型名文本); reflect: true会被转换为字段上的"reflects": true,这正是给框架包装器或文档工具判断属性是否需要反射的关键信息;@state()属性(如aState)没有出现在attributes数组中,因为 state 默认attribute: false,但字段本身仍会输出。
类型引用(references):位置偏移量与包解析
CEM 允许在type.text之外附带references数组,标注类型文本中每个标识符的真实定义位置。转换器在 convertTypeReferences 中通过在类型文本里顺序查找符号名来计算start/end字符偏移:
const start = text.indexOf(ref.name, curr); curr = start + ref.name.length;例如CustomEvent<MyDetail>会生成两个引用,start: 0, end: 11指向CustomEvent,start: 12, end: 20指向MyDetail(见 golden 中my-detail-custom-event条目)。
convertReference 还负责解析引用的归属:
- 全局对象(
HTMLElement、Event、CustomEvent)→"package": "global:"; - 外部包(
LitElement、TemplateResult)→package取依赖包名(如"lit"、"lit-html"、"@lit/reactive-element"),存在时附上module; - 包内符号 →
package为包自身名称(@lit-internal/test-element-a),module为定义模块路径。
golden 中externalTypeVar: LitElement、globalTypeVar: HTMLElement、packageTypeVar: Foo<Bar>三个变量正是这三种情况的直接演示。
其他声明类型
- 函数:
convertFunctionDeclaration输出kind: 'function'、参数列表(含optional、rest、default)与返回值;golden 中function2还展示了@deprecated、参数/返回值描述等多行 JSDoc 的透传; - Mixin:
kind: 'mixin',同样携带参数与返回类型(mixin的泛型参数T会作为引用被解析); - 普通变量:
kind: 'variable'+type;若类型缺失则兜底为{text: 'unknown'}(源码中有 TODO 注释,指出 CEM 规范中该字段并非可选); - 事件:
convertEvent中若事件类型缺失,兜底为{text: 'Event'}。
空值裁剪:控制 manifest 体积的关键细节
custom-elements.json最终会被 IDE、文档工具反复解析,体积直接影响体验。为此 src/index.ts 定义了两个守卫函数:
const ifNotEmpty = <T>(v: T): T | undefined => { if ( (v as unknown) === false || ((typeof v === 'string' || Array.isArray(v)) && v.length === 0) ) { return undefined; } return v; };规则很简单:值为false、空字符串、空数组时返回undefined。由于JSON.stringify会直接跳过值为undefined的键,生成结果中就不会出现"summary": ""、"members": []这类无意义条目(golden 中element-without-props就没有attributes键,ElementMixins的attributes键同样被省略)。transformIfNotEmpty则是“先判空、再变换、再判空”的组合形态,用于declarations、events、slots等需要二次映射的字段。这是全文件被反复调用的模式,也是输出 JSON 之所以紧凑的原因。
测试与基准校验:golden 驱动的质量保障
该包的测试完全由 golden 文件驱动,核心测试在 src/test/generate_test.ts:
test('basic manifest generation', async () => { const project = 'test-element-a'; const inputPackage = path.resolve(testProjects, project); const analyzer = createPackageAnalyzer(inputPackage as AbsolutePath); const pkg = analyzer.getPackage(); await writeFileTree(outputFolder, await generateManifest(pkg)); await assertGoldensMatch(outputFolder, path.join('goldens', project), { formatGlob: '**/*.json', }); });流程为:对../test-projects/test-element-a运行分析 → 生成 manifest → 写入临时目录 → 与 goldens/test-element-a/custom-elements.json 逐字节比对。测试输出 JSON 会经过格式化(formatGlob: '**/*.json')保证比对稳定。
测试工程 test-element-a 内的每个源文件对应一类覆盖场景,可对照 golden 验证:
| 源文件 | 覆盖的转换特性 |
|---|---|
| element-a.ts | JSDoc(summary/fires/slot/cssProperty/cssPart)、子目录模块、别名重导出(Baz)、全局/包内/外部类型引用 |
| element-props.ts | 各类型@property的字段/属性映射、reflect、自定义 interface 类型 |
| element-events.ts | 多种事件 payload 类型(string/number/自定义类/TemplateResult)与事件引用解析 |
| element-mixins.ts | mixin 声明、mixins继承列表 |
| element-without-props.ts | 无属性元素的空值裁剪 |
当组件 API 行为变更需要更新基准时,可运行npm run test:update-goldens(即UPDATE_TEST_GOLDENS=true npm run test,见 package.json)自动重写 golden 文件后再人工审查 diff。
扩展与集成:把生成器接入自己的工具链
如果你不想依赖 CLI,而希望把 manifest 生成嵌入自己的构建流程,有三种思路:
- 直接调用
generateManifest,拿到FileTree后用自己的逻辑写盘(如合并进自定义发布流程); - 复用
getCommand()返回的命令对象,它满足 CLI 生成器接口(name/description/kind/generate),可以被任何遵循该接口的宿主加载; - 参照 CLI 的引用式加载:在 generate.ts 中,生成器以
{name, installFrom, importSpecifier}引用注册、按需安装并动态import(),你可以在自己的 CLI 中复刻这套“引用 → 解析 → 安装 → 导入 → 执行”的机制。
generateManifest的输出是纯数据(FileTree),不触达文件系统,因此可以方便地在内存中组合多个生成器产物(例如 manifest + React 包装器一起输出到--out目录)。
当前实现的限制与注意事项
源码注释明确标注了若干尚未完成或需要留意的点,使用时应知晓:
source.href与demos字段未输出:convertClassDeclaration与convertLitElementDeclaration中均有// TODO注释(source: {href: 'TODO'}、// demos: [], // TODO),因此目前 manifest 不包含源码链接与示例地址;- 个别字段使用非空断言:
convertCommonDeclarationInfo中的declaration.name!、变量类型的?? {text: 'unknown'}、事件类型的?? {text: 'Event'}都源于 CEM schema 中这些字段并非可选,转换器只能用兜底值保证输出合法; - 类型文本的准确度:
convertReactivePropertiesToAttributes的类型映射只对 String/Number/Boolean/Array/Object 保证理想结果,其余类型直接输出类型名文本,对 CEM 消费者而言“够用但不保证完整语义”; - 实验性定位:包名带
labs前缀(版本见 package.json,当前0.3.6),API 仍可能演进;要求 Node>= 14.8.0; - JSDoc 是元数据的源泉:描述、事件、插槽、CSS 自定义属性、CSS Parts 等丰富信息全部来自注释标签,注释缺失的元素在 manifest 中会相应“瘦身”。
小结
@lit-labs/gen-manifest是一个小而精的代码生成器:输入是@lit-labs/analyzer的静态分析结果,输出是符合社区规范、体积经过裁剪的custom-elements.json。它把“组件公共 API 文档化”这件事从人工维护变成自动生成,并且通过 golden 测试保证每次输出可复现、可审查。无论是想为自己的 Lit 组件库生成标准清单,还是想理解 CEM 生成管线的内部实现,都可以以 src/index.ts 与 golden 文件 为第一手参考。
【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考