✨ feat(vscode): 代码阅读 React 组件卡片加渲染大纲:原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」点开看全部类名,cond && 写「当 … 时」、三元分是 / 否两支、.map 写「对 … 的每一项」标 key、提前 return null 写不渲染、片段和 JSX 注释照写;差异测试覆盖加属性、改副作用依赖
This commit is contained in:
1 parent
ee807ec7a4
commit
fd8b03a563
4 files changed
+376
-16
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { PropertyRow, RuleContext } from '../contract'
|
||||
import type { PropertyItem, PropertyRow, RuleContext } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../blockView'
|
||||
import { diffBlockTrees } from '../diff/blockDiff'
|
||||
import { createTypeScriptAdapter } from '../languages/typescript'
|
||||
import { propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { propertyLines, readFixture, segmentText, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
||||
import { reactRule } from './react'
|
||||
|
||||
@@ -152,6 +153,16 @@ describe('react 库规则:属性', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 一层层子行里头一个满足 test 的。 */
|
||||
function findItem(items: PropertyItem[] | undefined, test: (item: PropertyItem) => boolean): PropertyItem | undefined {
|
||||
for (const item of items ?? []) {
|
||||
const found = test(item) ? item : findItem(item.items, test)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 块和它的子块写成文字:「种类 行号 @行/子行」(放进父卡片属性表里的写在哪),子块多缩进两格。 */
|
||||
function outlineOf(view: BlockView, pad = ''): string[] {
|
||||
const slot = view.slot ? ` @${view.slot.row}${view.slot.item !== undefined ? `/${view.slot.item}` : ''}` : ''
|
||||
@@ -161,7 +172,7 @@ function outlineOf(view: BlockView, pad = ''): string[] {
|
||||
describe('react 库规则:分区', () => {
|
||||
it('luffy 的 LegalDocPage:状态(渲染中重置写进改它的)/ 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理,讲进卡片的语句不再单独成块', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
expect(propertyLines(properties(view)).slice(4)).toEqual([
|
||||
expect(propertyLines(properties(view)).slice(4, 20)).toEqual([
|
||||
'状态: 2 个',
|
||||
' activeSection | headings[0]?.id ?? "" | setActiveSection(渲染时,当 doc 不全等 prevDoc 时;副作用 2)',
|
||||
' prevDoc | doc | setPrevDoc(渲染时,当 doc 不全等 prevDoc 时)',
|
||||
@@ -188,7 +199,6 @@ describe('react 库规则:分区', () => {
|
||||
' react.handler 104 @8/0',
|
||||
' react.handler 112 @8/1',
|
||||
' react.handler 116 @8/2',
|
||||
' return 124',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -246,3 +256,123 @@ describe('react 库规则:分区', () => {
|
||||
expect(rowLines(poster, '副作用')).toEqual(['副作用: 1 个'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('react 库规则:渲染大纲', () => {
|
||||
it('luffy 的 DesktopTitlebar:整张卡片讲完了组件,没有留下的子块;渲染大纲每行一个元素,原生标签、组件、样式小标、属性、插值', async () => {
|
||||
const view = await card(TITLEBAR, 'DesktopTitlebar')
|
||||
expect(view.children).toEqual([])
|
||||
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '属性', '读取的数据', '派生值', '渲染'])
|
||||
expect(rowLines(view, '渲染')).toEqual([
|
||||
'渲染: 1 处返回',
|
||||
' div ⟦样式 8 个类|⟧ data-tauri-drag-region="deep"',
|
||||
' div ⟦样式 3 个类|⟧',
|
||||
' ⟦|当 platform 全等 "macos" 时⟧ span ⟦样式 1 个类|⟧ data-testid="traffic-light-inset" aria-hidden',
|
||||
' div ⟦样式 6 个类|⟧',
|
||||
' span ⟦样式 4 个类|⟧ {center}',
|
||||
' ⟦|当 platform 全等 "windows" 时⟧ WindowControls',
|
||||
])
|
||||
const row = properties(view).find(item => item.label === '渲染')
|
||||
const [div] = row?.items ?? []
|
||||
const [tagSegment, , chip] = div.cells[0]
|
||||
expect(tagSegment).toEqual({ text: 'div', role: 'plain', color: { dark: '#808080', light: '#6f6f6f' } })
|
||||
expect(chip).toMatchObject({ style: 'classes', detail: 'flex h-10 shrink-0 select-none items-center border-b border-border bg-background' })
|
||||
const controls = div.items?.[2].cells[0].find(piece => 'role' in piece && piece.text === 'WindowControls')
|
||||
expect(controls).toMatchObject({ role: 'type', color: { dark: '#61dafb', light: '#0a7ea4' } })
|
||||
expect(controls && 'role' in controls && TITLEBAR.slice(controls.offset, (controls.offset ?? 0) + 14)).toBe('WindowControls')
|
||||
})
|
||||
|
||||
it('luffy 的 LegalDocPage:JSX 注释、随条件切换的类(点开看每种情况的类)、列表(key、其中先算的常量)、三元的是 / 否两支,return 并进卡片', async () => {
|
||||
const view = await card(LEGAL, 'LegalDocPage')
|
||||
expect(view.children.map(child => child.kind)).not.toContain('return')
|
||||
const lines = rowLines(view, '渲染')
|
||||
expect(lines.slice(0, 4)).toEqual([
|
||||
'渲染: 1 处返回',
|
||||
' div ⟦样式 9 个类|⟧',
|
||||
' // 顶部导航栏: 返回 + 文档切换 + 打印',
|
||||
' header ⟦样式 13 个类|⟧',
|
||||
])
|
||||
expect(lines).toContain(' button ⟦样式 10 个类|⟧ type="button" onClick=handleBack aria-label="返回"')
|
||||
expect(lines).toContain(' Link ⟦样式 12 个类 · 随 doc 变|⟧ href="/terms"')
|
||||
expect(lines).toContain(' ⟦|对 headings 的每一项 h⟧ key h.id')
|
||||
expect(lines).toContain(' 其中 isActive = activeSection 全等 h.id')
|
||||
expect(lines).toContain(' button ⟦样式 7 个类 · 随 isActive 变|⟧ type="button" onClick=() => handleSectionClick(h.id) {h.title}')
|
||||
const ternary = lines.indexOf(' ⟦|如果 markdown.trim()⟧')
|
||||
expect(lines.slice(ternary, ternary + 4)).toEqual([
|
||||
' ⟦|如果 markdown.trim()⟧',
|
||||
' ⟦|是⟧ div ⟦样式 3 个类|⟧',
|
||||
' ReactMarkdown remarkPlugins=[remarkGfm] components=对象:h2、a {markdown}',
|
||||
' ⟦|否⟧ p ⟦样式 2 个类|⟧ {DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。',
|
||||
])
|
||||
const link = findItem(properties(view).find(item => item.label === '渲染')?.items, item => segmentText(item.cells[0]).startsWith('Link'))
|
||||
const chip = link?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
|
||||
expect(chip && !('role' in chip) && chip.detail).toBe([
|
||||
'flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300',
|
||||
'doc 全等 "terms" 时:bg-surface text-foreground shadow-sm',
|
||||
'否则:text-muted-foreground hover:text-foreground',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('几处返回分几段:提前 return null 写「当 … 时 不渲染」,最后一处是「否则」;片段写「片段,不产生元素」', async () => {
|
||||
const settings = readFixture('desktopSettingsDialog.tsx.txt')
|
||||
expect(rowLines(await card(settings, 'DesktopSettingsDialog'), '渲染').slice(0, 4)).toEqual([
|
||||
'渲染: 2 处返回',
|
||||
' ⟦|当 非 section 时⟧ 不渲染',
|
||||
' ⟦|否则⟧',
|
||||
' Dialog open onOpenChange=(next) => 非 next 并且 closeSettings()',
|
||||
])
|
||||
expect(rowLines(await card(readFixture('appVaultSummary.tsx.txt'), 'AppVaultSummary'), '渲染').slice(0, 5)).toEqual([
|
||||
'渲染: 1 处返回',
|
||||
' <> 片段,不产生元素',
|
||||
' SettingHeader compact align="center" title="密钥与变量" description="按应用保存,这个应用的所有会话共用" right=<Link>',
|
||||
' ⟦|当 effective.length > 0 时⟧ ul ⟦样式 2 个类|⟧',
|
||||
' ⟦|对 effective 的每一项 { item, label }⟧ key item.id',
|
||||
])
|
||||
expect(rowLines(await card(settings, 'RuntimeSection'), '渲染')).toContain(' p ⟦样式 3 个类|⟧ {w.subdir} · {STATE_NOTE[w.state]}')
|
||||
})
|
||||
|
||||
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元写成是 / 否两支', async () => {
|
||||
const lines = rowLines(await card(readFixture('deleteAppDialog.tsx.txt'), 'DeleteAppDialog'), '渲染')
|
||||
const button = lines.indexOf(' Button variant="destructive" onClick=() => remove.mutate() disabled=非 ready 或者 pending')
|
||||
expect(lines.slice(button + 1, button + 4)).toEqual([
|
||||
' ⟦|如果 pending⟧',
|
||||
' ⟦|是⟧ "删除中…"',
|
||||
' ⟦|否⟧ "确认删除并清空数据"',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('react 库规则:代码差异', () => {
|
||||
it('给组件加一个属性、改一个副作用的依赖:组件卡片标修改、属性表那一行新加,副作用子卡片标修改、时机逐截标', async () => {
|
||||
const after = LEGAL
|
||||
.replace(' doc: "terms" | "privacy";\n}', ' doc: "terms" | "privacy";\n /** 窄屏时收起目录 */\n compact?: boolean;\n}')
|
||||
.replace('export const LegalDocPage = ({ doc }: LegalDocPageProps) => {', 'export const LegalDocPage = ({ doc, compact = false }: LegalDocPageProps) => {')
|
||||
.replace(' scrollRef.current?.scrollTo({ top: 0 });\n }, [doc]);', ' scrollRef.current?.scrollTo({ top: 0 });\n }, [doc, markdown]);')
|
||||
expect(after).not.toBe(LEGAL)
|
||||
const before = await views(LEGAL)
|
||||
const now = await views(after)
|
||||
const diff = diffBlockTrees({ blocks: before, source: LEGAL }, { blocks: now, source: after })
|
||||
const component = now.find(view => view.kind === 'react.component') as BlockView
|
||||
const effect = component.children.find(child => child.kind === 'react.effect') as BlockView
|
||||
|
||||
const componentChange = diff.blocks[component.anchor]
|
||||
expect(componentChange).toMatchObject({ change: 'mod', own: true })
|
||||
const propsIndex = properties(component).findIndex(row => row.label === '属性')
|
||||
expect(Object.keys(componentChange.marks?.rows?.changed ?? {})).toEqual([String(propsIndex)])
|
||||
expect(componentChange.marks?.rows?.changed[propsIndex].items?.changed[1]).toEqual({ added: true })
|
||||
expect(componentChange.marks?.rows?.removed).toEqual([])
|
||||
|
||||
const effectChange = diff.blocks[effect.anchor]
|
||||
expect(effectChange).toMatchObject({ change: 'mod', own: true })
|
||||
expect(effectChange.marks?.rows?.changed[0].value).toEqual({
|
||||
inline: [
|
||||
{ segments: [{ text: 'doc', role: 'variable', offset: expect.any(Number) }] },
|
||||
{ segments: [{ text: '、', role: 'hint' }, { text: 'markdown', role: 'variable', offset: expect.any(Number) }], change: 'add' },
|
||||
{ segments: [{ text: ' ', role: 'plain' }, { text: '变了时', role: 'keyword' }] },
|
||||
],
|
||||
})
|
||||
// 另一个副作用、处理函数没变,不标
|
||||
const unchanged = component.children.filter(child => child !== effect && child.kind !== 'comment')
|
||||
expect(unchanged.map(child => diff.blocks[child.anchor])).toEqual(unchanged.map(() => undefined))
|
||||
expect(diff.stats).toEqual({ add: 0, mod: 2, del: 0 })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
|
||||
import type { BindingFacts, BodyFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, Place, PropsFacts, ReadFacts } from './reactTree'
|
||||
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
|
||||
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts } from './reactTree'
|
||||
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, openTag, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
|
||||
import { hint, joined, name, plain, range } from './segments'
|
||||
|
||||
/**
|
||||
@@ -10,7 +10,9 @@ import { hint, joined, name, plain, range } from './segments'
|
||||
* 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、
|
||||
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
|
||||
* 副作用(每个 useEffect / useLayoutEffect 一张子卡片:时机 / 做什么 / 清理)、事件处理(组件里定义的函数:名称 / 绑定在 / 做什么,
|
||||
* 点名称展开它的子卡片)。讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
|
||||
* 点名称展开它的子卡片)、渲染(返回的 JSX 画成缩进的结构大纲:原生标签灰色、组件用组件色能跳到定义,className 收成「样式 N 个类」,
|
||||
* `cond &&` 写「当 … 时」,三元分「是 / 否」两支,`.map` 写「对 … 的每一项」并标 key,提前 return null 写「当 … 时 不渲染」)。
|
||||
* 讲进分区的语句并进卡片;副作用、处理函数(和主体是语句块的 useMemo / useCallback)照常是块,放进卡片那一行下面。
|
||||
* 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。
|
||||
* 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。
|
||||
*/
|
||||
@@ -34,7 +36,7 @@ const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' }
|
||||
|
||||
interface Words {
|
||||
kinds: Record<'component' | 'effect' | 'handler', string>
|
||||
labels: Record<'name' | 'doc' | 'props' | 'state' | 'refs' | 'reads' | 'derived' | 'effects' | 'handlers' | 'timing' | 'does' | 'cleanup' | 'parameters', string>
|
||||
labels: Record<'name' | 'doc' | 'props' | 'state' | 'refs' | 'reads' | 'derived' | 'effects' | 'handlers' | 'render' | 'timing' | 'does' | 'cleanup' | 'parameters', string>
|
||||
/** 各分区表格的列 */
|
||||
heads: Record<'state' | 'refs' | 'reads' | 'derived' | 'handlers', string[]>
|
||||
/** 副作用的时机、派生值什么时候算:a、b 变了时,只在挂载时,每次渲染后,每次渲染 */
|
||||
@@ -74,6 +76,26 @@ interface Words {
|
||||
context: [string, string]
|
||||
customHook: string
|
||||
libraryHook: (module: string) => string
|
||||
/** 渲染:「N 处返回」,大纲一行前面的「当 … 时」「如果 …」「是 / 否 / 否则」「对 … 的每一项」、不渲染 */
|
||||
returns: (n: number) => string
|
||||
when: [string, string]
|
||||
if: string
|
||||
branches: Record<'yes' | 'no' | 'otherwise', string>
|
||||
noRender: string
|
||||
eachItem: [string, string]
|
||||
/** 列表的 key、回调里先算的常量:「key h.id」「其中 isActive = …」 */
|
||||
key: string
|
||||
local: string
|
||||
/** <></> 片段 */
|
||||
fragment: string
|
||||
/** 跨几行写的对象属性值只写有哪些键:「对象:h2、a」 */
|
||||
object: string
|
||||
/** 样式小标:「样式 N 个类 · 随 doc 变」;点开的全文里按条件切换的一行「条件 时:类」、「否则:类」,算出来的「另加 表达式」 */
|
||||
classes: (n: number) => string
|
||||
classesBy: (subjects: string) => string
|
||||
classesWhen: (condition: string) => string
|
||||
classesElse: string
|
||||
classesExtra: string
|
||||
tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string>
|
||||
/** 属性名后面的标签:函数类型的属性、children */
|
||||
roles: Record<'callback' | 'children', string>
|
||||
@@ -107,6 +129,7 @@ const ZH: Words = {
|
||||
derived: '派生值',
|
||||
effects: '副作用',
|
||||
handlers: '事件处理',
|
||||
render: '渲染',
|
||||
timing: '时机',
|
||||
does: '做什么',
|
||||
cleanup: '清理',
|
||||
@@ -163,6 +186,21 @@ const ZH: Words = {
|
||||
context: ['从 ', ' 上下文读'],
|
||||
customHook: '自定义钩子',
|
||||
libraryHook: module => `${module} 的钩子`,
|
||||
returns: n => `${n} 处返回`,
|
||||
when: ['当', '时'],
|
||||
if: '如果',
|
||||
branches: { yes: '是', no: '否', otherwise: '否则' },
|
||||
noRender: '不渲染',
|
||||
eachItem: ['对', '的每一项'],
|
||||
key: 'key ',
|
||||
local: '其中 ',
|
||||
fragment: '片段,不产生元素',
|
||||
object: '对象:',
|
||||
classes: n => `样式 ${n} 个类`,
|
||||
classesBy: subjects => ` · 随 ${subjects} 变`,
|
||||
classesWhen: condition => `${condition} 时:`,
|
||||
classesElse: '否则:',
|
||||
classesExtra: '另加 ',
|
||||
tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' },
|
||||
roles: { callback: '回调', children: '子元素' },
|
||||
propHeads: ['名称', '类型', '默认值', '必填'],
|
||||
@@ -699,6 +737,191 @@ function nestedCard(block: CodeBlock, env: Env): RuleCard | undefined {
|
||||
return callback && fn ? handlerCard(block, callback.name, fn, commentsBefore(callback.statement), env) : undefined
|
||||
}
|
||||
|
||||
// ─── 渲染 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 着色的词拼成的字(框只算框里的词),写进样式小标点开的全文。 */
|
||||
function flatText(pieces: readonly Inline[]): string {
|
||||
return pieces.map(piece => 'role' in piece ? piece.text : flatText(piece.segments)).join('')
|
||||
}
|
||||
|
||||
/** 大纲一行前面的小框(见 GroupStyle 的 condition / branch / skip / iteration)。 */
|
||||
function pill(style: SegmentGroup['style'], segments: Inline[]): SegmentGroup {
|
||||
return { style, label: '', segments }
|
||||
}
|
||||
|
||||
/** 「当 cond 时」。 */
|
||||
function whenText(condition: Node, { context, words }: Env): Inline[] {
|
||||
return [keyword(words.when[0]), plain(' '), ...context.translate(condition), plain(' '), keyword(words.when[1])]
|
||||
}
|
||||
|
||||
/** className 按条件切换时是随什么变的:比较的左边(`doc === "terms"` 随 doc)、取反的那个(`!x` 随 x),别的写法就是它自己。 */
|
||||
function classSubject(condition: Node): string {
|
||||
const value = unwrap(condition)
|
||||
if (value?.type === 'binary_expression')
|
||||
return value.childForFieldName('left')?.text ?? value.text
|
||||
if (value?.type === 'unary_expression')
|
||||
return value.childForFieldName('argument')?.text ?? value.text
|
||||
return value?.text ?? condition.text
|
||||
}
|
||||
|
||||
/** 长 className 收成的「样式 N 个类」小标,点开、悬停看全部类名(按条件切换的一行写一条);一个类都没写时没有。 */
|
||||
function classesChip(classes: ClassFacts, { context, words }: Env): SegmentGroup | undefined {
|
||||
const { base, conditions, dynamic } = classes
|
||||
if (base.length === 0 && conditions.length === 0 && dynamic.length === 0)
|
||||
return undefined
|
||||
const subjects = [...new Set([...conditions.map(item => classSubject(item.condition)), ...dynamic.map(node => node.text)])]
|
||||
const detail = [
|
||||
...(base.length > 0 ? [base.join(' ')] : []),
|
||||
...conditions.flatMap(item => [
|
||||
`${words.classesWhen(flatText(context.translate(item.condition)))}${item.yes.join(' ')}`,
|
||||
...(item.no.length > 0 ? [`${words.classesElse}${item.no.join(' ')}`] : []),
|
||||
]),
|
||||
...dynamic.map(node => `${words.classesExtra}${flatText(context.translate(node))}`),
|
||||
]
|
||||
return {
|
||||
style: 'classes',
|
||||
label: `${words.classes(base.length)}${subjects.length > 0 ? words.classesBy(subjects.join(words.listSeparator)) : ''}`,
|
||||
segments: [],
|
||||
detail: detail.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
/** 标签名:组件用组件色、能跳到定义(`a.b` 一节节都能跳),原生标签灰色;片段写 `<>`。 */
|
||||
function tagName(nameNode: Node | undefined, component: boolean): Inline[] {
|
||||
if (!nameNode)
|
||||
return [{ text: '<>', role: 'plain', color: NATIVE_TAG_COLOR }]
|
||||
if (!component)
|
||||
return [{ text: nameNode.text, role: 'plain', color: NATIVE_TAG_COLOR }]
|
||||
const parts = (node: Node): Inline[] => {
|
||||
if (node.type !== 'member_expression')
|
||||
return [{ ...name(node, 'type'), color: COMPONENT_COLOR }]
|
||||
const object = node.childForFieldName('object')
|
||||
const property = node.childForFieldName('property')
|
||||
return [...(object ? parts(object) : []), { text: '.', role: 'plain', color: COMPONENT_COLOR }, ...(property ? [{ ...name(property, 'type'), color: COMPONENT_COLOR }] : [])]
|
||||
}
|
||||
return parts(nameNode)
|
||||
}
|
||||
|
||||
/** 一个属性写成「名字=值」:字符串、表达式照常翻,跨几行写的对象只写有哪些键,JSX 元素写成 `<Tag>`;只写了名字的就是名字,`{...x}` 照写。 */
|
||||
function attributeText(attribute: Node, env: Env): Inline[] {
|
||||
const { context, words } = env
|
||||
if (attribute.type === 'jsx_expression')
|
||||
return [plain('{'), ...context.translate(named(attribute).find(child => child.type !== 'comment') ?? attribute), plain('}')]
|
||||
const nameNode = named(attribute)[0]
|
||||
const value = attributeValue(attribute)
|
||||
const head: Inline = { text: nameNode?.text ?? '', role: 'variable', ...(nameNode && { offset: nameNode.startIndex }) }
|
||||
if (!value)
|
||||
return [head]
|
||||
const inner = value.type === 'jsx_expression' ? unwrap(named(value).find(child => child.type !== 'comment')) : value
|
||||
let shown: Inline[]
|
||||
if (!inner)
|
||||
shown = context.translate(value)
|
||||
else if (inner.type === 'jsx_element' || inner.type === 'jsx_self_closing_element')
|
||||
shown = elementRef(openTag(inner))
|
||||
else if (inner.type === 'object' && inner.startPosition.row !== inner.endPosition.row)
|
||||
shown = [hint(words.object), ...joined(named(inner).flatMap(pair => pair.childForFieldName('key') ? [[name(pair.childForFieldName('key') as Node, 'variable')]] : []), hint(words.listSeparator))]
|
||||
else
|
||||
shown = context.translate(inner)
|
||||
return [head, plain('='), ...shown]
|
||||
}
|
||||
|
||||
/** 写在同一行的文字、插值:只有一段文字时带引号(「"法律与隐私"」),插值写成 `{…}`,和文字混着时文字不带引号。 */
|
||||
function inlineText(nodes: readonly Node[], { context }: Env): Inline[] {
|
||||
// 文字里的换行、缩进并成一个空格,整行两头的空白去掉;和插值挨着的空格留着(「{a} · {b}」)
|
||||
const texts = nodes.map((node, i) => {
|
||||
let text = node.text.replace(/\s+/g, ' ')
|
||||
if (i === 0)
|
||||
text = text.trimStart()
|
||||
if (i === nodes.length - 1)
|
||||
text = text.trimEnd()
|
||||
return text
|
||||
})
|
||||
if (nodes.length === 1 && nodes[0].type === 'jsx_text')
|
||||
return [{ text: `"${texts[0]}"`, role: 'string' }]
|
||||
return nodes.flatMap((node, i): Inline[] => {
|
||||
if (node.type === 'jsx_expression') {
|
||||
const inner = named(node).find(child => child.type !== 'comment')
|
||||
return inner ? [hint('{'), ...context.translate(inner), hint('}')] : []
|
||||
}
|
||||
if (node.type === 'jsx_text')
|
||||
return texts[i] ? [{ text: texts[i], role: 'string' }] : []
|
||||
return context.translate(node)
|
||||
})
|
||||
}
|
||||
|
||||
/** 一行里的几段,用空格隔开。 */
|
||||
function spaced(parts: Inline[][]): Inline[] {
|
||||
return joined(parts.filter(part => part.length > 0), plain(' '))
|
||||
}
|
||||
|
||||
/** 大纲的几个节点写成子行;prefix 是写在第一行最前面的小框(三元的「是 / 否」)。 */
|
||||
function outlineItems(nodes: readonly OutlineNode[], env: Env, prefix: Inline[] = []): PropertyItem[] {
|
||||
return nodes.flatMap((node, index) => outlineNode(node, env, index === 0 ? prefix : []))
|
||||
}
|
||||
|
||||
/** 大纲的一个节点:元素一行(子元素在下一层)、文字、JSX 注释、三元(下面是 / 否两支)、列表(下面是每一项渲染的)、空。 */
|
||||
function outlineNode(node: OutlineNode, env: Env, prefix: Inline[]): PropertyItem[] {
|
||||
const { context, words } = env
|
||||
const when = 'when' in node && node.when ? [pill('condition', whenText(node.when, env))] : []
|
||||
const line = (parts: Inline[][], children: PropertyItem[] = []): PropertyItem[] =>
|
||||
[{ cells: [spaced([prefix, when, ...parts])], ...(children.length > 0 && { items: children }) }]
|
||||
switch (node.kind) {
|
||||
case 'element': {
|
||||
const chip = node.classes && classesChip(node.classes, env)
|
||||
return line([
|
||||
tagName(node.name, node.component),
|
||||
node.name ? [] : [hint(words.fragment)],
|
||||
chip ? [chip] : [],
|
||||
joined(node.attributes.map(attribute => attributeText(attribute, env)), plain(' ')),
|
||||
node.text.length > 0 ? inlineText(node.text, env) : [],
|
||||
], outlineItems(node.children, env))
|
||||
}
|
||||
case 'text':
|
||||
return line([inlineText(node.nodes, env)])
|
||||
case 'comment':
|
||||
return [{ cells: [spaced([prefix, [{ text: `// ${commentText([node.node])}`, role: 'comment' }]])] }]
|
||||
case 'ternary':
|
||||
return line([[pill('condition', [keyword(words.if), plain(' '), ...context.translate(node.condition)])]], [
|
||||
...outlineItems(node.yes, env, [pill('branch', [keyword(words.branches.yes)])]),
|
||||
...outlineItems(node.no, env, [pill('branch', [keyword(words.branches.no)])]),
|
||||
])
|
||||
case 'map':
|
||||
return line([
|
||||
[pill('iteration', [keyword(words.eachItem[0]), plain(' '), ...context.translate(node.source), plain(' '), keyword(words.eachItem[1]), ...(node.item ? [plain(' '), ...context.translate(node.item)] : [])])],
|
||||
node.key ? [hint(words.key), ...context.translate(node.key)] : [],
|
||||
], [
|
||||
...node.locals.map((local): PropertyItem => {
|
||||
const localName = local.childForFieldName('name')
|
||||
const value = local.childForFieldName('value')
|
||||
return { cells: [[hint(words.local), ...(localName ? binding(localName, 'variable', env) : []), plain(' = '), ...(value ? context.translate(value) : [])]] }
|
||||
}),
|
||||
...outlineItems(node.children, env),
|
||||
])
|
||||
default:
|
||||
return line([[hint(words.noRender)]])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染:返回的 JSX 画成缩进的结构大纲,每行一个元素。只有一处返回时就是它;几处返回时分几段,各带条件:
|
||||
* 提前返回 null 的写「当 … 时 不渲染」,提前返回 JSX 的「当 … 时」下面是它渲染的,最后一处是「否则」。
|
||||
*/
|
||||
function renderRow(analysis: Analysis, env: Env): PropertyRow {
|
||||
const { words } = env
|
||||
const { returns } = analysis.body
|
||||
const items = returns.length === 1 && !returns[0].when
|
||||
? outlineItems(outlineOf(returns[0].value), env)
|
||||
: returns.flatMap((part): PropertyItem[] => {
|
||||
if (!part.value)
|
||||
return [{ cells: [spaced([[pill('skip', part.when ? whenText(part.when, env) : [keyword(words.branches.otherwise)])], [hint(words.noRender)]])] }]
|
||||
return [{
|
||||
cells: [[pill('branch', part.when ? whenText(part.when, env) : [keyword(words.branches.otherwise)])]],
|
||||
items: outlineItems(outlineOf(part.value), env),
|
||||
}]
|
||||
})
|
||||
return { label: words.labels.render, value: [hint(words.returns(returns.length))], ...(items.length > 0 && { items }) }
|
||||
}
|
||||
|
||||
// ─── 认领 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 组件卡片。 */
|
||||
@@ -757,6 +980,10 @@ function componentCard(block: CodeBlock, component: ComponentFacts, env: Env): R
|
||||
absorb(handler.comments[0])
|
||||
})
|
||||
}
|
||||
if (body.returns.length > 0) {
|
||||
rows.push(renderRow(analysis, env))
|
||||
absorb(...body.returns.map(part => part.statement))
|
||||
}
|
||||
return {
|
||||
kind: KIND,
|
||||
badge: badge(words),
|
||||
|
||||
@@ -73,21 +73,23 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[],
|
||||
}
|
||||
const folded = folding.collapsed.has(id)
|
||||
const Chevron = folded ? ChevronRight : ChevronDown
|
||||
const toggle = (): void => folding.onToggleFold(id)
|
||||
// 只有箭头是按钮:这一列里还可能有别的按钮(渲染大纲的「样式 N 个类」),按钮里不能套按钮;点这一列的字照样切换
|
||||
return (
|
||||
<div className="group flex min-w-0 items-start gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="group flex min-w-0 items-start gap-1 rounded-sm text-left"
|
||||
className="flex h-5 w-3.5 shrink-0 items-center rounded-sm"
|
||||
aria-label={`${folded ? '展开' : '折叠'}${inlineText(cell)}`}
|
||||
aria-expanded={!folded}
|
||||
onClick={() => folding.onToggleFold(id)}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="flex h-5 w-3.5 shrink-0 items-center">
|
||||
<Chevron size={14} strokeWidth={1.8} className="text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
|
||||
</span>
|
||||
<span className="cr-code min-w-0">
|
||||
<SegmentText segments={cell} />
|
||||
</span>
|
||||
</button>
|
||||
<p className="cr-code min-w-0 cursor-pointer" onClick={toggle}>
|
||||
<SegmentText segments={cell} />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,8 @@ function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
|
||||
function ClassesChip({ group }: { group: SegmentGroup }): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<span className="cr-classes">
|
||||
// 点小标、点弹层里的字都不往外传:大纲里点这一行的字会折叠它的子行,点小标只开关类名
|
||||
<span className="cr-classes" onClick={event => event.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="cr-group"
|
||||
|
||||
Reference in new issue
Block a user