✨ feat(vscode): 代码阅读的 React 组件卡片:原子图标,名称后面跟具名导出 / 默认导出 / 客户端组件 / 记忆化 / 转发 ref 小标签,属性表按属性一行(类型取字段、默认值、必填、说明,来自 XxxProps 可点,导入的类型经工作区索引查),"use client" 和组件上的 JSDoc 不再单独成块

This commit is contained in:
cruldra committed 2026-10-03 03:11:46 +08:00
1 parent 1a32a51e75
commit 15f3a6600d
14 files changed
+666 -34

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor, WorkspaceIndex } from '../contract'
import type { BindingFacts, ComponentFacts, FieldFacts, FieldText, PropsFacts } from './reactTree'
import { clientDirective, commentText, componentAt, componentOf, componentsIn, declaredTypes, propsOf, sameNode } from './reactTree'
import { hint, name, plain, range } from './segments'
/**
* React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段;
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区。
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
*/
const RULE_ID = 'react'
const KIND = 'react.component'
/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */
const COMPONENT_COLOR: ThemeColor = { dark: '#61dafb', light: '#0a7ea4' }
/** 导出方式的标签:同 import / export 关键字的颜色。 */
const EXPORT_COLOR: ThemeColor = { dark: '#c586c0', light: '#af00db' }
/** 记忆化、转发 ref 的标签:同 const / function 关键字的颜色。 */
const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' }
interface Words {
kinds: Record<'component', string>
labels: Record<'name' | 'doc' | 'props', string>
tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string>
/** 属性名后面的标签:函数类型的属性、children */
roles: Record<'callback' | 'children', string>
/** 属性表的列:名称 | 类型 | 默认值 | 必填,有属性带说明时多一列说明 */
propHeads: string[]
propDoc: string
yes: string
no: string
/** 表里没有的值 */
nothing: string
/** 没写类型 */
unmarked: string
count: (n: number) => string
/** 属性来自哪个类型:「 · 来自 」 */
from: string
/** `...rest` 的类型:其余的属性 */
rest: string
/** 解构时改了名:「(组件里叫 x)」 */
renamed: [string, string]
}
const ZH: Words = {
kinds: { component: 'React 组件' },
labels: { name: '名称', doc: '说明', props: '属性' },
tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' },
roles: { callback: '回调', children: '子元素' },
propHeads: ['名称', '类型', '默认值', '必填'],
propDoc: '说明',
yes: '是',
no: '否',
nothing: '—',
unmarked: '未标注',
count: n => n > 0 ? `${n} 个` : '无',
from: ' · 来自 ',
rest: '其余的属性',
renamed: ['(组件里叫 ', ')'],
}
const WORDS: Record<string, Words> = { zh: ZH }
interface Env {
context: RuleContext
words: Words
}
/** 小标签(见 GroupStyle 的 tag)。 */
function tag(label: string, color: ThemeColor): SegmentGroup {
return { style: 'tag', label, segments: [], color }
}
/** 规则自己的用词里当关键字着色的:是、否。 */
function yesNo(value: boolean, words: Words): Inline[] {
return [{ text: value ? words.yes : words.no, role: 'storage' }]
}
// ─── 名称、说明 ───────────────────────────────────────────────────────────────
/** 名称:组件名用组件色,后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签。 */
function nameRow(component: ComponentFacts, { words }: Env): PropertyRow {
const tags = [
...(component.exported ? [tag(words.tags[component.exported], EXPORT_COLOR)] : []),
...(component.client ? [tag(words.tags.client, COMPONENT_COLOR)] : []),
...component.wrappers.map(wrapper => tag(words.tags[wrapper], WRAPPER_COLOR)),
]
return {
label: words.labels.name,
value: [{ ...name(component.name, 'function'), color: COMPONENT_COLOR }, ...tags.flatMap(item => [plain(' '), item])],
}
}
/** 说明:组件上面的 JSDoc。 */
function docRows(component: ComponentFacts, { context, words }: Env): PropertyRow[] {
const doc = component.doc && context.doc(component.doc)
return doc ? [{ label: words.labels.doc, value: doc }] : []
}
// ─── 属性 ─────────────────────────────────────────────────────────────────────
/** 工作区里各文件顶层声明的接口、类型别名:名字 → 各字段的文字(JSON,见 FieldText),组件的属性类型是导入的时候查它。 */
const PROP_TYPES_INDEX: WorkspaceIndex = {
id: `${RULE_ID}.propTypes`,
languageId: 'typescriptreact',
include: '**/*.{ts,tsx}',
mentions: source => /\b(?:interface|type)\s+[A-Z]/.test(source),
entries: root => declaredTypes(root).map(({ name: typeName, fields }) => ({ key: typeName.text, name: range(typeName), value: JSON.stringify(fields) })),
}
/** 属性表的一行讲的一个属性:这个文件里的字段、查索引得到的别的文件里的字段,和解构出来的那一项(都可能没有)。 */
interface PropEntry {
key: string
field?: FieldFacts
text?: FieldText
binding?: BindingFacts
}
/** 查索引得到的别的文件里的字段;值不是这个规则存的样子时当没查到。 */
function importedFields(from: Node | undefined, { context }: Env): FieldText[] | undefined {
const value = from && context.lookup(PROP_TYPES_INDEX, from)?.value
if (!value)
return undefined
try {
const parsed: unknown = JSON.parse(value)
return Array.isArray(parsed) ? parsed as FieldText[] : undefined
}
catch {
return undefined
}
}
/**
* 属性表的各行:属性类型的字段照类型里的顺序(解构出来的那项给默认值、定义处),类型里没有的解构项接在后面,`...rest` 最后;
* 类型查不到字段、又没解构时是参数本身一行。
*/
function propEntries(props: PropsFacts, env: Env): PropEntry[] {
const texts = props.fields ? undefined : importedFields(props.from, env)
const keys = props.fields?.map(field => field.name.text) ?? texts?.map(text => text.name)
const bindingOf = (key: string): BindingFacts | undefined => props.bindings.find(binding => !binding.rest && binding.key === key)
const declared: PropEntry[] = props.fields
? props.fields.map(field => ({ key: field.name.text, field, ...withBinding(bindingOf(field.name.text)) }))
: (texts ?? []).map(text => ({ key: text.name, text, ...withBinding(bindingOf(text.name)) }))
const extra = props.bindings.filter(binding => binding.rest || !keys?.includes(binding.key)).map(binding => ({ key: binding.key, binding }))
const entries = [...declared, ...extra.filter(entry => !entry.binding.rest), ...extra.filter(entry => entry.binding.rest)]
if (entries.length === 0 && props.parameter && !keys)
return [{ key: props.parameter.text }]
return entries
}
function withBinding(binding: BindingFacts | undefined): { binding?: BindingFacts } {
return binding ? { binding } : {}
}
/** 名称一格:属性名(定义处能跳),children、回调后面跟小标签,解构时改了名写出组件里叫什么。 */
function propName(entry: PropEntry, env: Env): Inline[] {
const { words } = env
const { binding, field, text } = entry
const renamed = binding && !binding.rest && binding.name.text !== entry.key
const head: Inline[] = field
? [name(field.name, 'variable')]
: binding && !renamed ? [...(binding.rest ? [plain('...')] : []), name(binding.name, 'variable')] : [{ text: entry.key, role: 'variable' }]
const role = entry.key === 'children' ? words.roles.children : (field?.callback ?? text?.callback) ? words.roles.callback : undefined
return [
...head,
...(role ? [plain(' '), tag(role, COMPONENT_COLOR)] : []),
...(renamed ? [hint(words.renamed[0]), name(binding.name, 'variable'), hint(words.renamed[1])] : []),
]
}
/** 类型一格:字段的类型照常翻,方法签名写成函数签名;别的文件里的字段照写文字;`...rest` 是其余的属性。 */
function propType(entry: PropEntry, props: PropsFacts, { context, words }: Env): Inline[] {
const { field, text, binding } = entry
if (field?.type?.type === 'formal_parameters')
return [...context.translate(field.type), plain(' => '), ...(field.returns ? context.translate(field.returns) : [hint(words.unmarked)])]
if (field?.type)
return context.translate(field.type)
if (text?.type)
return [{ text: text.type, role: 'type' }]
if (binding?.rest)
return [hint(words.rest)]
if (!binding && props.type)
return context.translate(props.type)
return [hint(words.unmarked)]
}
/** 必填一格:类型里写了 `?` 的不必填;没有类型可看时解构写了默认值的不必填,否则不知道。 */
function propRequired(entry: PropEntry, words: Words): Inline[] {
const optional = entry.field?.optional ?? entry.text?.optional
if (optional !== undefined)
return yesNo(!optional, words)
if (entry.binding?.default || entry.binding?.rest)
return yesNo(false, words)
return [hint(words.nothing)]
}
/** 说明一格:字段上面的 JSDoc / 注释。 */
function propDoc(entry: PropEntry): Inline[] {
const text = entry.field ? commentText(entry.field.docs) : entry.text?.doc
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : []
}
/** 属性:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「属性」后面写来自哪里。 */
function propsRow(component: ComponentFacts, env: Env): PropertyRow {
const { context, words } = env
const props = propsOf(component)
const entries = propEntries(props, env)
const docs = entries.map(propDoc)
const described = docs.some(doc => doc.length > 0)
const items = entries.map((entry, index): PropertyItem => {
const defines = entry.binding?.name ?? entry.field?.name
return {
cells: [
propName(entry, env),
propType(entry, props, env),
entry.binding?.default ? context.translate(entry.binding.default) : [hint(words.nothing)],
propRequired(entry, words),
...(described ? [docs[index]] : []),
],
...(defines && { defines: range(defines) }),
}
})
return {
label: words.labels.props,
value: [hint(words.count(items.length)), ...(props.from ? [hint(words.from), name(props.from, 'type')] : [])],
...(items.length > 0 && { items, columns: described ? [...words.propHeads, words.propDoc] : words.propHeads }),
}
}
// ─── 认领 ─────────────────────────────────────────────────────────────────────
/** 组件卡片。 */
function componentCard(component: ComponentFacts, env: Env): RuleCard {
return {
kind: KIND,
badge: badge(env.words),
rows: [nameRow(component, env), ...docRows(component, env), propsRow(component, env)],
defines: range(component.name),
absorbs: [],
}
}
function badge(words: Words): KindBadge {
return { label: words.kinds.component, color: COMPONENT_COLOR, icon: 'atom' }
}
/**
* 讲在了组件卡片里、不再单独成块的:文件开头的 "use client"(文件里有组件时,讲成「客户端组件」标签),
* 紧贴在组件前面、没并进组件块头部的 JSDoc(值是 memo(…)、直接返回 JSX 的箭头函数这类常量块,讲成「说明」)。
*/
function omitted(block: CodeBlock): boolean {
const root = block.node.tree.rootNode
if (block.kind === 'statement')
return sameNode(clientDirective(root), block.node) && componentsIn(root).length > 0
if (block.kind === 'comment') {
const next = block.node.nextSibling
const component = next ? componentAt(next) : undefined
return !!component?.doc && sameNode(component.doc, block.node)
}
return false
}
export const reactRule: LibraryRule = {
id: RULE_ID,
label: 'React 组件',
languageIds: ['typescriptreact', 'javascriptreact'],
libraries: [],
claim: (block, context) => {
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH }
const component = componentOf(block)
if (component)
return componentCard(component, env)
return omitted(block) ? { omit: true } : undefined
},
}