✨ 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

+17 -7
View File
@@ -310,18 +310,21 @@ export function toBlockViews(
}
/**
* 块先问启用的库规则,没认领就照它自己的样子。认领了的:规则讲的是整条语句,头部就是整个范围;
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。规则略去的块(讲在了别的卡片里)不出块。
*/
function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView {
function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView | undefined {
const claim = active.length > 0 ? claimBlock(block, active, context, layerRules.report) : undefined
if (!claim)
return plain(block, span, id, scope)
const kept = block.children.filter(child => !claim.block.absorbs.includes(child))
if ('as' in claim.block) {
const replacement = claim.block
if ('omit' in replacement)
return undefined
const kept = block.children.filter(child => !replacement.absorbs.includes(child))
if ('as' in replacement) {
const { attributes } = block
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
}
const { kind, badge, rows, defines, attributes } = claim.block
const { kind, badge, rows, defines, attributes } = replacement
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span))
return {
id,
@@ -336,9 +339,16 @@ export function toBlockViews(
properties: rows,
}
}
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。 */
function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] {
const spans = blockSpans(siblings, source)
return siblings.map((block, i) => build(block, spans[i], `${prefix}${from + i}`, scope))
const views: BlockView[] = []
siblings.forEach((block, i) => {
const view = build(block, spans[i], `${prefix}${from + views.length}`, scope)
if (view)
views.push(view)
})
return views
}
return list
}
+32 -9
View File
@@ -545,18 +545,30 @@ export type TokenRole
*/
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange }
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */
export type GroupStyle = 'iteration'
/**
* 框的样式,决定框头的图标和框的颜色:
* - iteration:写在表达式里的遍历(渲染大纲里 `.map` 的「对 … 的每一项」也是),图标、颜色同「循环」块;
* - condition:按条件(渲染大纲里 `cond &&` 的「当 … 时」、三元的「如果 …」),图标、颜色同「条件」块;
* - branch:条件的一支(「是」「否」「否则」),颜色同「条件」块,不带图标;
* - skip:这个条件下不渲染(提前 `return null`),图标、颜色同「返回」块;
* - tag:小标签(名称后面的「具名导出」、属性名后面的「回调」),只有框头的字,颜色是 SegmentGroup.color;
* - classes:收起来的一串类名(「样式 N 个类」),中性色,点开、悬停看 SegmentGroup.detail。
*/
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes'
/**
* 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历),预览把它画成嵌在前后文字里的一个小框,框头是图标和 label。
* 框里的词照样带源码下标,还能再套框。
* 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历、渲染大纲一行前面的条件),预览把它画成嵌在前后文字里的一个小框,
* 框头是图标和 label。框里的词照样带源码下标,还能再套框。
*/
export interface SegmentGroup {
style: GroupStyle
/** 框头的文字(遍历的结果:生成器 / 列表 …),翻译插件给。 */
/** 框头的文字(遍历的结果:生成器 / 列表 …,小标签的字),翻译插件、库规则给;只有图标、没有字时是空的。 */
label: string
segments: Inline[]
/** 这个框自己的颜色(暗色、亮色主题各一个),盖过样式的颜色:tag 按标签的意思上色。 */
color?: ThemeColor
/** 点开、悬停时看的全文,一行一条:classes 的全部类名(按条件切换的写成「条件 时:类」)。 */
detail?: string
}
/** 译文里的一段:着色的词,或框起来的一组词。 */
@@ -722,7 +734,7 @@ export interface ThemeColor { dark: string, light: string }
/**
* 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个:
* 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置共用 sliders-horizontal,字段、标签共用 tag),
* 后面的给库规则的种类;库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
* 后面的给库规则的种类(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标),
* 没有按任意名字找图标的路。
*/
export const KIND_ICON_NAMES = [
@@ -758,6 +770,9 @@ export const KIND_ICON_NAMES = [
'calendar-clock',
'globe',
'network',
'atom',
'zap',
'mouse-pointer-click',
] as const
export type KindIconName = (typeof KIND_ICON_NAMES)[number]
@@ -767,9 +782,16 @@ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconNam
/**
* 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块:
* 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts)。
* 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts);
* 这块讲在了别的卡片里时是略去它(RuleOmit)。
*/
export type RuleBlock = RuleCard | RuleFacts
export type RuleBlock = RuleCard | RuleFacts | RuleOmit
/**
* 这块讲在了别的卡片里,不再单独成块:React 文件开头的 "use client" 讲成了组件卡片上的「客户端组件」标签,
* 组件上面的 JSDoc 讲成了卡片的「说明」。预览不画它;它不能有子块(有子块时宿主当规则没认)。
*/
export interface RuleOmit { omit: true }
/** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给(用词归规则)。 */
export interface RuleCard {
@@ -884,7 +906,8 @@ export interface LibraryRule {
languageIds: string[]
/**
* 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。
* 宿主按语言插件给的导入判断,规则不用自己读导入语句。
* 宿主按语言插件给的导入判断,规则不用自己读导入语句。空数组是不看导入、languageIds 里这几种语言的源码都启用
* (React 组件:新的 JSX 写法不用导入 react,TSX / JSX 文件里就是 React 组件)。
*/
libraries: string[]
/**
+4 -2
View File
@@ -114,9 +114,11 @@ export function removedView(view: BlockView): BlockView {
}
}
/** 一个词比较用的样子:词性和字(框是样式、框头和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */
/** 一个词比较用的样子:词性和字(框是样式、框头、点开看的全文和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */
function pieceKey(piece: Inline): string {
return 'role' in piece ? `${piece.role}\u0000${piece.text}` : `${piece.style}\u0000${piece.label}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]`
if ('role' in piece)
return `${piece.role}\u0000${piece.text}`
return `${piece.style}\u0000${piece.label}\u0000${piece.detail ?? ''}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]`
}
/** 着色的词拼成的字(框只算框里的词)。 */
+2
View File
@@ -29,6 +29,7 @@ import { celeryRule } from './rules/celery'
import { dataclassRule } from './rules/dataclass'
import { enumRule } from './rules/enum'
import { fastapiRule } from './rules/fastapi'
import { reactRule } from './rules/react'
import { sqlalchemyRule } from './rules/sqlalchemy'
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
import { bashZhTranslator } from './translators/bashZh'
@@ -143,6 +144,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
api.registerLibraryRule(enumRule),
api.registerLibraryRule(dataclassRule),
api.registerLibraryRule(fastapiRule),
api.registerLibraryRule(reactRule),
)
return api
}
@@ -88,6 +88,11 @@ describe('库规则:启用', () => {
expect(list.map(view => view.kind)).toEqual(['import', 'class'])
expect(reports).toEqual([])
})
it('libraries 是空的规则不看导入:这种语言的文件都启用', async () => {
expect(await activates('import os', { ...cardRule('any'), libraries: [] })).toBe(true)
expect(await activates('x = 1', { ...cardRule('any'), libraries: [] })).toBe(true)
})
})
describe('库规则:认领', () => {
@@ -177,6 +182,17 @@ describe('库规则:认领', () => {
])
})
it('略去的块不出块,后面的块 id 接着往下数;还有子块的块不能略去,记下来、照常显示', async () => {
const omitting = (kind: string): LibraryRule => ({ ...cardRule('omit'), claim: block => block.kind === kind ? { omit: true } : undefined })
const list = await views(MODEL, [omitting('import')])
expect(list.map(view => [view.id, view.kind])).toEqual([['0', 'class']])
expect(list[0].children.map(child => child.id)).toEqual(['0.0', '0.1', '0.2', '0.3'])
const reports: string[] = []
expect((await views(MODEL, [omitting('class')], reports)).map(view => view.kind)).toEqual(['import', 'class'])
expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块'])
})
it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => {
const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({
...cardRule('lib'),
+4 -2
View File
@@ -26,10 +26,10 @@ export function fromLibrary(module: string, library: string): boolean {
return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`)
}
/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库。 */
/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库,或规则不看导入(libraries 是空的)。 */
export function activeRules(rules: readonly LibraryRule[], imports: readonly ImportFacts[]): LibraryRule[] {
const modules = imports.flatMap(facts => facts.sources.map(moduleName))
return rules.filter(rule => rule.libraries.some(library => modules.some(module => fromLibrary(module, library))))
return rules.filter(rule => rule.libraries.length === 0 || rule.libraries.some(library => modules.some(module => fromLibrary(module, library))))
}
/** 一块被哪个规则认领、替身是什么。 */
@@ -40,6 +40,8 @@ export interface Claim {
/** 替身不合 RuleBlock 的约定时说哪儿不对。 */
function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined {
if ('omit' in claimed)
return block.children.length > 0 ? '要略去的块还有子块' : undefined
if (claimed.absorbs.some(child => !block.children.includes(child)))
return '要并进来的块不是这块的子块'
if ('as' in claimed)
+153
View File
@@ -0,0 +1,153 @@
import type { BlockView } from '../blockView'
import type { PropertyRow, RuleContext } from '../contract'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createTypeScriptAdapter } from '../languages/typescript'
import { propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { reactRule } from './react'
const ts = createTypeScriptAdapter(WASM_DIR)
const TITLEBAR = readFixture('desktopTitlebar.tsx.txt')
const LEGAL = readFixture('legalDocPage.tsx.txt')
const POSTER = readFixture('posterDialog.tsx.txt')
const SHARE = readFixture('shareButton.tsx.txt')
/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */
function known(values: Record<string, string>): RuleContext['lookup'] {
return (_index, node) => values[node.text] === undefined ? undefined : { key: node.text, name: { startIndex: 0, endIndex: 0 }, value: values[node.text] }
}
async function views(source: string, lookup?: RuleContext['lookup'], reports: string[] = []): Promise<BlockView[]> {
return withBlocks(ts, 'typescriptreact', source, blocks =>
toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { rules: [reactRule], report: message => reports.push(message), lookup }))
}
function flatten(list: readonly BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
function properties(view: BlockView | undefined): PropertyRow[] {
if (!view || !('properties' in view))
throw new Error(`块 ${view?.id} 不是属性表`)
return view.properties
}
/** 组件名是 name 的组件卡片。 */
async function card(source: string, name: string, lookup?: RuleContext['lookup']): Promise<BlockView> {
const found = flatten(await views(source, lookup)).find(view => view.kind === 'react.component' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === name)
if (!found)
throw new Error(`没有 ${name} 的组件卡片`)
return found
}
/** 卡片里 label 那一行(连子行)写成的文字,见 propertyLines。 */
function rowLines(view: BlockView, label: string): string[] {
const row = properties(view).find(item => item.label === label)
return row ? propertyLines([row]) : []
}
describe('react 库规则:认领', () => {
it('只管 TSX / JSX,不看导入(新的 JSX 写法不用导入 react)', () => {
expect(reactRule.languageIds).toEqual(['typescriptreact', 'javascriptreact'])
expect(reactRule.libraries).toEqual([])
})
it('luffy 的 DesktopTitlebar:五条导入后面是组件卡片,"use client" 和文档注释不再单独成块', async () => {
const blocks = await views(TITLEBAR)
expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['import 3', 'import 5', 'import 6', 'import 7', 'import 9', 'react.component 11'])
const view = blocks[5]
expect(view.badge).toEqual({ label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' })
expect(view.attributes).toMatchObject({ kind: 'react.component', access: 'public' })
expect(propertyLines(properties(view)).slice(0, 4)).toEqual([
'名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧',
'说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.',
'属性: 1 个',
' platform | DesktopShell["platform"] | — | 是',
])
})
it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => {
expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'const'])
const source = ['import { memo } from "react";', '/** 一行标签 */', 'export const Label = memo(({ text }: { text: string }) => <span>{text}</span>);'].join('\n')
const blocks = await views(source)
expect(blocks.map(view => view.kind)).toEqual(['import', 'react.component'])
expect(propertyLines(properties(blocks[1])).slice(0, 2)).toEqual(['名称: Label ⟦具名导出|⟧ ⟦记忆化|⟧', '说明: 一行标签'])
})
it('小写开头的工具函数、不渲染 JSX 的大写函数照常是函数块', async () => {
const blocks = await views(['const slugify = (raw: string) => raw.trim();', 'function Upper() { return 1 }', 'function lower() { return <i /> }'].join('\n'))
expect(blocks.map(view => view.kind)).toEqual(['const', 'function', 'function'])
})
})
describe('react 库规则:属性', () => {
it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const row = properties(view).find(item => item.label === '属性')
expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明'])
expect(rowLines(view, '属性')).toEqual([
'属性: 1 个 · 来自 LegalDocPageProps',
' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策',
])
const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined
expect(from && 'role' in from && LEGAL.slice(from.offset, (from.offset ?? 0) + from.text.length)).toBe('LegalDocPageProps')
expect(row?.items?.[0].defines && LEGAL.slice(row.items[0].defines.startIndex, row.items[0].defines.endIndex)).toBe('doc')
})
it('luffy 的 ShareButton:回调标「回调」、类型写成函数签名;StepLabel 的 children 标「子元素」', async () => {
expect(rowLines(await card(SHARE, 'ShareButton'), '属性')).toEqual([
'属性: 5 个',
' open | 布尔 | — | 是 | ',
' onOpenChange ⟦回调|⟧ | (next: 布尔) => 无 | — | 是 | ',
' deployUrl | 字符串 | 空 | — | 是 | 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。',
' sessionId | 字符串 | — | 是 | ',
' title | 字符串 | — | 是 | ',
])
expect(rowLines(await card(POSTER, 'StepLabel'), '属性')).toEqual([
'属性: 2 个',
' n | 数值 | — | 是',
' children ⟦子元素|⟧ | ReactNode | — | 是',
])
})
it('默认值、`?` 不必填、改了名、其余属性;方法签名写成函数签名', async () => {
const source = [
'interface Props { size?: number; onPick(v: string): void; label: string }',
'export const A = ({ size = 16, label: text, ...rest }: Props) => <i />;',
].join('\n')
expect(rowLines(await card(source, 'A'), '属性')).toEqual([
'属性: 4 个 · 来自 Props',
' size | 数值 | 16 | 否',
' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是',
' label(组件里叫 text) | 字符串 | — | 是',
' ...rest | 其余的属性 | — | 否',
])
})
it('导入的属性类型:工作区索引查到了就展开字段,没查到时照解构的写、类型未标注;没写参数是无', async () => {
const source = [
'import type { CardProps } from "./types";',
'export const Card = ({ title, onClose = noop }: CardProps) => <div />;',
'export function Empty() { return <i /> }',
].join('\n')
expect(rowLines(await card(source, 'Card'), '属性')).toEqual([
'属性: 2 个 · 来自 CardProps',
' title | 未标注 | — | —',
' onClose | 未标注 | noop | 否',
])
const fields = JSON.stringify([
{ name: 'title', type: 'string', optional: false, callback: false, doc: '标题' },
{ name: 'onClose', type: '() => void', optional: true, callback: true },
{ name: 'size', type: 'number', optional: true, callback: false },
])
expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '属性')).toEqual([
'属性: 3 个 · 来自 CardProps',
' title | string | — | 是 | 标题',
' onClose ⟦回调|⟧ | () => void | noop | 否 | ',
' size | number | — | 否 | ',
])
expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无'])
})
})
+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
},
}
+6 -1
View File
@@ -182,7 +182,12 @@ export function directives(root: Node): Node[] {
/** 文件开头写了 "use client":这个文件里的组件在浏览器里渲染(Next.js 的客户端组件)。 */
export function isClientFile(root: Node): boolean {
return directives(root).some(statement => stringContent(syntax(statement)[0]) === 'use client')
return !!clientDirective(root)
}
/** 文件开头那句 "use client";没写时没有。 */
export function clientDirective(root: Node): Node | undefined {
return directives(root).find(statement => stringContent(syntax(statement)[0]) === 'use client')
}
/** 名字怎么导出的:声明前面写了 export(带 default 是默认导出),或文件里别处 `export default 名字` / `export { 名字 }`。 */
@@ -31,3 +31,19 @@ describe('着色的词', () => {
expect(html).toBe('<span class="cr-role-string" data-offset="20">/users/</span><span class="cr-role-variable" data-offset="90">user_id</span>')
})
})
describe('框起来的一组词', () => {
it('小标签只有框头的字,颜色写在 style 上;按条件的前缀只有图标、框里的词照常上色', () => {
const tag = renderToStaticMarkup(<SegmentText segments={[{ style: 'tag', label: '具名导出', segments: [], color: { dark: '#c586c0', light: '#af00db' } }]} />)
expect(tag).toBe('<span class="cr-group" data-style="tag" data-colored="" style="--group-dark:#c586c0;--group-light:#af00db"><span class="cr-group-head">具名导出</span></span>')
const condition = renderToStaticMarkup(<SegmentText segments={[{ style: 'condition', label: '', segments: [{ text: '当', role: 'keyword' }] }]} />)
expect(condition).toMatch(/^<span class="cr-group" data-style="condition"><span class="cr-group-head" data-bare=""><svg[^>]*>.*<\/svg><\/span><span class="cr-role-keyword">当<\/span><\/span>$/)
const branch = renderToStaticMarkup(<SegmentText segments={[{ style: 'branch', label: '', segments: [{ text: '是', role: 'keyword' }] }]} />)
expect(branch).toBe('<span class="cr-group" data-style="branch"><span class="cr-role-keyword">是</span></span>')
})
it('收起来的类名是个按钮:悬停提示是全部类名,收着时不弹层', () => {
const html = renderToStaticMarkup(<SegmentText segments={[{ style: 'classes', label: '样式 2 个类', segments: [], detail: 'flex gap-2' }]} />)
expect(html).toBe('<span class="cr-classes"><button type="button" class="cr-group" data-style="classes" aria-expanded="false" data-tooltip="flex gap-2"><span class="cr-group-head">样式 2 个类</span></button></span>')
})
})
@@ -1,7 +1,7 @@
import type { LucideIcon } from 'lucide-react'
import type { CSSProperties, ReactElement } from 'react'
import type { GroupStyle, Inline, SegmentGroup } from './types'
import { useContext } from 'react'
import { useContext, useState } from 'react'
import { DisplayNameContext, shownName } from './displayNames'
import { KIND_ICON_COMPONENTS, KIND_ICONS } from './kindIcons'
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
@@ -42,19 +42,62 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
)
}
/** 框的样式 → 框头图标:遍历同「循环」块的图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */
const GROUP_ICONS: Record<GroupStyle, LucideIcon> = { iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop] }
/**
* 框的样式 → 框头图标:遍历同「循环」块、按条件同「条件」块、不渲染同「返回」块的图标;条件的一支、小标签、类名不带图标。
* 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。
*/
const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop],
condition: KIND_ICON_COMPONENTS[KIND_ICONS.if],
branch: undefined,
skip: KIND_ICON_COMPONENTS[KIND_ICONS.return],
tag: undefined,
classes: undefined,
}
/** 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。 */
/** 框自己的颜色,暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 .cr-group[data-colored])。 */
function groupColor(group: SegmentGroup): CSSProperties | undefined {
return group.color && { '--group-dark': group.color.dark, '--group-light': group.color.light } as CSSProperties
}
/**
* 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。框头是图标和 label,只有图标、只有字、都没有都行;
* 收起来的类名另见 ClassesChip。
*/
function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
if (group.style === 'classes')
return <ClassesChip group={group} />
const Icon = GROUP_ICONS[group.style]
return (
<span className="cr-group" data-style={group.style}>
<span className="cr-group-head">
<Icon size={12} strokeWidth={2} aria-hidden />
{group.label}
</span>
<span className="cr-group" data-style={group.style} data-colored={group.color ? '' : undefined} style={groupColor(group)}>
{(Icon || group.label) && (
<span className="cr-group-head" data-bare={group.label ? undefined : ''}>
{Icon && <Icon size={12} strokeWidth={2} aria-hidden />}
{group.label}
</span>
)}
<SegmentText segments={group.segments} />
</span>
)
}
/** 收起来的一串类名(「样式 N 个类」):悬停看全部类名,点一下在它下面弹出、盖住后面的字,再点收起。 */
function ClassesChip({ group }: { group: SegmentGroup }): ReactElement {
const [open, setOpen] = useState(false)
return (
<span className="cr-classes">
<button
type="button"
className="cr-group"
data-style="classes"
data-open={open ? '' : undefined}
aria-expanded={open}
data-tooltip={open ? undefined : group.detail}
onClick={() => setOpen(value => !value)}
>
<span className="cr-group-head">{group.label}</span>
</button>
{open && <span className="cr-classes-popup" role="note">{group.detail}</span>}
</span>
)
}
@@ -381,6 +381,73 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
color: var(--group);
white-space: nowrap;
}
/* 只有图标的框头(渲染大纲一行前面的「当 … 时」):图标和后面的字贴近一点;框里没有词时(小标签、类名)框头后面不留空 */
.cr-group-head[data-bare] {
margin-right: 4px;
}
.cr-group-head:last-child {
margin-right: 0;
}
/*
* 渲染大纲一行前面的小框(React 组件卡片):按条件、条件的一支同「条件」块的颜色,不渲染同「返回」块;
* 小标签(名称后面的「具名导出」、属性名后面的「回调」)用规则给的颜色(暗色、亮色各一个,按主题挑)
*/
.cr-group:is([data-style="condition"], [data-style="branch"]) { --group: var(--cr-kind-branch); }
.cr-group[data-style="skip"] { --group: var(--cr-kind-return); }
.cr-group[data-colored] { --group: var(--group-dark); }
body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --group: var(--group-light); }
.cr-group[data-style="tag"] {
line-height: 16px;
margin-top: 1px;
}
/*
* 收起来的一串类名(「样式 N 个类」):中性色的小框,是个按钮;点开时换成焦点色的边、在下面弹出全部类名,
* 弹层盖住后面的字,不挤开它们
*/
.cr-group[data-style="classes"] {
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
background: var(--vscode-input-background);
border-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder, transparent));
}
.cr-group[data-style="classes"] .cr-group-head {
font-weight: 400;
color: var(--vscode-descriptionForeground);
}
.cr-group[data-style="classes"]:hover,
.cr-group[data-style="classes"][data-open] {
border-color: var(--vscode-focusBorder);
}
.cr-group[data-style="classes"][data-open] .cr-group-head {
color: var(--vscode-foreground);
}
.cr-classes {
position: relative;
display: inline-block;
vertical-align: top;
}
.cr-classes-popup {
position: absolute;
top: calc(100% + 2px);
left: 0;
z-index: 20;
width: max-content;
max-width: min(460px, 70vw);
padding: 6px 8px;
border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border));
border-radius: 6px;
background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background));
box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%));
color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground));
font-family: var(--vscode-editor-font-family, monospace);
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
}
/*
* 属性表里带列名的子行(输入参数、数据表的字段)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线;
@@ -3,6 +3,7 @@ import type { ReactElement } from 'react'
import type { BlockKind, BlockView, KindIconName } from './types'
import {
Activity,
Atom,
Box,
Braces,
CalendarClock,
@@ -17,6 +18,7 @@ import {
ListOrdered,
Lock,
MessageSquareText,
MousePointerClick,
Network,
Package,
Play,
@@ -34,6 +36,7 @@ import {
Terminal,
TriangleAlert,
Variable,
Zap,
} from 'lucide-react'
/** 插件定义的这组种类图标,每个名字一个 lucide 图标(逐个 import,打包只带这些)。 */
@@ -70,6 +73,9 @@ export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
'calendar-clock': CalendarClock,
'globe': Globe,
'network': Network,
'atom': Atom,
'zap': Zap,
'mouse-pointer-click': MousePointerClick,
}
/**
+13 -4
View File
@@ -28,14 +28,23 @@ export interface Segment { text: string, role: TokenRole, offset?: number, refer
/** 规则自己查得到定义的一个名字:哪个规则、规则自己认的键 */
export interface RuleReference { rule: string, key: string }
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块 */
export type GroupStyle = 'iteration'
/**
* 框的样式,决定框头的图标和框的颜色:iteration 写在表达式里的遍历(同「循环」块),condition 按条件(同「条件」块),
* branch 条件的一支(同「条件」块的颜色、不带图标),skip 这个条件下不渲染(同「返回」块),tag 小标签(颜色是 SegmentGroup.color),
* classes 收起来的一串类名(中性色,点开、悬停看 detail)
*/
export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes'
/** 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label;框里的词照样带源码下标,还能再套框 */
/**
* 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label(只有图标时 label 是空的);框里的词照样带源码下标,还能再套框。
* color 盖过样式的颜色(tag 按标签的意思上色),detail 是点开、悬停时看的全文(classes 的全部类名,一行一条)
*/
export interface SegmentGroup {
style: GroupStyle
label: string
segments: Inline[]
color?: ThemeColor
detail?: string
}
/** 译文里的一段:着色的词,或框起来的一组词 */
@@ -90,7 +99,7 @@ export type KindIconName
= | 'import' | 'square-function' | 'box' | 'braces' | 'list-ordered' | 'triangle-alert' | 'tag' | 'lock' | 'variable'
| 'split' | 'corner-down-right' | 'repeat' | 'shield' | 'shield-alert' | 'shield-check' | 'corner-down-left' | 'code'
| 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play'
| 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network'
| 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' | 'atom' | 'zap' | 'mouse-pointer-click'
/** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }