279 lines
13 KiB
TypeScript
279 lines
13 KiB
TypeScript
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
|
||
},
|
||
}
|