📝 docs: 新增代码阅读设计文档与 Figma 原型插件

This commit is contained in:
cruldra committed 2026-09-25 00:41:45 +08:00
1 parent 46735827d2
commit 83bdfbe9c4
6 files changed
+1892

No files matched your search

@@ -0,0 +1,84 @@
# 代码阅读器 Design Lab
插件新注册的「代码阅读」入口的 Figma 原型。它和现有的 Superpowers 看板(小火箭)是两套东西,只是放在同一个插件里:
- VS Code 左侧活动栏多一个图标,点开是一个和自带资源管理器一样的文件树。
- 在树上点文件,用编辑器打开;编辑器右上角切「编辑 / 预览」。
- 「预览」把代码拆成一棵代码块树:导入、定义、变量、如果、循环、返回……每一句是一块,嵌套的块往右缩进,左边竖线标出层级。
- 每块默认显示译文,点块右边的「编辑」换成这一块的源码,点「预览」换回来。
- 译文是模板直译:结构照抄原语言,只把关键字、内置类型、运算符换成中文,名字和写法原样保留,每个关键字单独上色、前后有空格。例如 `export async function findEnvFiles(workspaceRoot: string): Promise<string[]>` 译成 `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>`。注释原文照搬。
- 块左边有种类标签(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释),底色也带这个种类的颜色;有子块的块左边有折叠箭头。
- 译文按词的来源上色,配色和 VS Code 默认深色主题一致:流程关键字(导入 / 来自 / 如果 / 返回 / 遍历 / 等待 / 公共 / 尝试 / 捕获)紫红,声明关键字(常量 / 函数 / 异步 / 接口 / 新建)和运算符(全等 / 非 / 或 / 与)蓝,变量浅蓝,函数黄,类型青,字符串橙,字面量淡绿。源码状态同样上色。
## 画了什么
| 画板 | 对应源码 | 设计点 |
| --- | --- | --- |
| `Prototype / envLock.ts · 编辑` | `vscode/src/files/envLock.ts` | 活动栏新图标选中,侧栏文件树,文件在编辑器里打开,右上角「编辑」亮 |
| `Prototype / envLock.ts · 预览` | 同上 | 代码块树:函数里套如果、循环里套尝试 / 出错时,最深 4 层;一块停在「编辑」状态做对照 |
| `Prototype / issueLock.ts · 编辑 / 预览` | `vscode/src/panel/issueLock.ts` | 中文注释块、多行返回语句块 |
| `Prototype / envLock.ts · 预览(编辑区变窄)` | — | 窗口只有 1000 宽时块跟着变窄,长句换行 |
## 导入与运行
1. 打开 Figma Desktop,打开任意 Design 文件。
2. `Plugins > Development > Import plugin from manifest…`,选本目录的 `manifest.json`(已经导入过就跳过)。
3. `Plugins > Development > 代码阅读器 Design Lab` 运行。改过 `code.js` 后要重启 Figma 才会用新代码。
## 运行条件
- 需要一种中文字体:PingFang SC、Noto Sans CJK SC、Noto Sans SC、Source Han Sans SC 任一。
- 等宽字体可选:JetBrains Mono、SF Mono、Menlo 任一;都没有时代码用中文字体显示。
- 文件里要有空余的页面名额(免费版文件有页面数上限)。
## 生成内容
每次运行新建一页 `代码阅读器 · Design Lab`(重名时加 2、3…),不动已有页面。
组件集(画板右侧一列):
- `Reader / CodeBlock`:13 种 `Kind` × `State=preview | edit`,共 26 个。两种状态都带「译文」和「源码」两层文字,只切换显示哪层,所以实例里改过的两段文字切状态时都保留。
- `Reader / BlockTree`:每个示例文件一整棵代码块树,预览画板直接放它的实例。
- `Reader / ModeToggle`:编辑器右上角的「编辑 / 预览」。
- `Explorer / TreeRow`、`Explorer / Header`:侧栏文件树。
- `Window / TitleBar`、`Window / ActivityBar`、`Window / StatusBar`、`Editor / TitleBar`、`Editor / Breadcrumb`:VS Code 窗口外壳。
## 数值来源
- 颜色:VS Code Dark Modern 主题,`code.js` 的 `COLORS` 里注明了对应的主题变量;层级竖线用 `editorIndentGuide.background`。
- 源码:两个示例文件的快照,写在 `code.js` 的 `SOURCES`。
- 代码块怎么拆、每块的种类:写在 `code.js` 的 `CODE_TREE`,测试会检查每一行源码都落进了某一块(空行和收尾括号除外)。
- 译文:`translateBlock` 按 `WORD_ZH`(关键字、内置类型)和 `OPERATOR_ZH`(运算符)逐词替换生成,原型只覆盖两个示例文件用到的 TypeScript 写法。
- 种类颜色:`BLOCK_KINDS`;高亮配色:`COLORS` 里 `token*` 几项,取自 VS Code Dark+。源码上色是原型里的简易规则,真实插件里颜色直接来自语法树。
- 文件树里的 `M` 标记是示例状态;文件图标是示意,真实插件的树会跟着你装的文件图标主题走。
## 原型交互
从 `Prototype / envLock.ts · 预览` 开始演示(已设为起点):
- 块右边「编辑」→ 这一块换成源码;「预览」→ 换回译文。
- 编辑器右上角「编辑」→ 整个文件回到 VS Code 编辑器;「预览」→ 回到代码块树。
- 侧栏点 `envLock.ts` / `issueLock.ts` → 在编辑器里打开那个文件。
- 编辑区和侧栏可以上下滚动。
## 本地检查
```bash
node --check code.js
node --test test.js
```
测试跑在假的 Figma 文档上,只检查结构、组件复用、连线、排布不重叠和图层数量,不量真实字体、不渲染。**测试通过不等于视觉验收**,要在 Figma 里看。
## 桌面验收清单
- [ ] 运行几秒内完成,新页面出现,已有页面没被改动
- [ ] 活动栏小火箭下面的书本图标是选中状态
- [ ] 侧栏看起来和 VS Code 自带资源管理器一致
- [ ] 代码块树:每层左边一条竖线,嵌套的块往右缩进,竖线从这一层第一块连到最后一块
- [ ] 块里长句子换行,右边「编辑 / 预览」按钮不被挤走
- [ ] 停在「编辑」状态的块显示上了色的源码、蓝框,中文注释不是方块
- [ ] 不同种类的块一眼能分开:标签颜色、底色、边框都不同;同一种类(比如三个「返回」)颜色一致
- [ ] 译文里的关键字、变量名、函数名颜色和编辑器里对应的代码一致,关键字前后都有空格
- [ ] 有子块的块左边有折叠箭头,没有子块的没有,标签仍然对齐
- [ ] 演示:块的编辑 / 预览、右上角编辑 / 预览、侧栏切文件都能点通
+967
View File
@@ -0,0 +1,967 @@
// 代码阅读器 Design Lab — local Figma Desktop development plugin.
// 插件新注册的「代码阅读」入口:活动栏一个独立图标,侧栏是和 VS Code 资源管理器一样的文件树;
// 打开文件后编辑器右上角切「编辑 / 预览」。预览把代码拆成一棵代码块树:
// 导入、定义、变量、如果、循环、返回……每一句都是一块,嵌套的块往右缩进,左边竖线标出层级;
// 每块默认显示模板直译的译文,点「编辑」换成这一块的源码,点「预览」换回来。
// Uses only the Figma Plugin API: no network, no product data, no MCP.
const PAGE_NAME = "代码阅读器 · Design Lab";
const NAMESPACE = "lab";
const GENERATOR = "lab.code-reader.design-lab";
const WINDOW_W = 1440;
const WINDOW_NARROW = 1000;
const WINDOW_H = 900;
const TITLE_H = 30;
const STATUS_H = 22;
const MAIN_H = WINDOW_H - TITLE_H - STATUS_H;
const ACTIVITY_W = 48;
const SIDEBAR_W = 280;
const EDITOR_W = WINDOW_W - ACTIVITY_W - SIDEBAR_W - 1;
const TREE_W = 880;
const VIEW_PAD = 24;
// VS Code Dark Modern 主题的实际色值,注释是对应的主题变量。
const COLORS = {
editor: "#1f1f1f", // editor.background
side: "#181818", // sideBar.background / activityBar.background / titleBar.activeBackground
field: "#111111",
border: "#2b2b2b", // sideBar.border / panel.border
borderStrong: "#3c3c3c",
guide: "#404040", // editorIndentGuide.background
fg: "#cccccc", // foreground
fgStrong: "#e8e8e8",
desc: "#9d9d9d", // descriptionForeground
dim: "#6e7681", // editorLineNumber.foreground
activityIdle: "#868686", // activityBar.inactiveForeground
activityOn: "#d7d7d7", // activityBar.foreground
focus: "#0078d4", // focusBorder / activityBar.activeBorder
selection: "#04395e", // list.activeSelectionBackground
toggleOn: "#37373d",
gitModified: "#e2c08d", // gitDecoration.modifiedResourceForeground
code: "#d4d4d4",
comment: "#6a9955",
folder: "#8b9ba8",
// 语法高亮:VS Code Dark+ 的默认配色
tokenControl: "#c586c0", // keyword.control:import / from / export / if / for / return / try / catch / await
tokenStorage: "#569cd6", // storage:const / function / async / interface / new / true / false
tokenVariable: "#9cdcfe", // variable
tokenFunction: "#dcdcaa", // entity.name.function
tokenType: "#4ec9b0", // entity.name.type
tokenString: "#ce9178", // string
tokenLiteral: "#b5cea8", // constant.numeric
ts: "#3178c6", tsx: "#4fc1ff", json: "#cbcb41", md: "#519aba",
};
const FONT_FAMILIES = ["PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC"];
const MONO_FONTS = [{ family: "JetBrains Mono", style: "Regular" }, { family: "SF Mono", style: "Regular" }, { family: "Menlo", style: "Regular" }];
const CJK = /[ -鿿＀-￯]/;
// 源码快照(示例内容),生成于 2026-09-24:vscode/src/files/envLock.ts、vscode/src/panel/issueLock.ts
const SOURCES = {
envLock: ["/**"," * .env file lock / unlock helpers."," *"," * Recursively scans the main workspace folder for `.env*` files (skipping"," * `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or"," * 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`."," *"," * No persistent watcher — re-scan happens on every toggle, so newly created"," * `.env` files are only picked up the next time the user clicks the button."," */","","import { promises as fsp } from 'node:fs'","import { RelativePattern, workspace } from 'vscode'","","const ENV_PATTERN = '**/.env*'","const EXCLUDE_PATTERN = '{**/node_modules/**,**/.git/**,**/.claude/**}'","","/**"," * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`"," * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute"," * fs paths. Empty array if the workspace folder isn't currently open."," */","export async function findEnvFiles(workspaceRoot: string): Promise<string[]> {"," const folder = workspace.workspaceFolders?.find(f => f.uri.fsPath === workspaceRoot)"," if (!folder)"," return []"," const include = new RelativePattern(folder, ENV_PATTERN)"," const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)"," return uris.map(u => u.fsPath)","}","","export interface EnvLockResult {"," total: number"," ok: string[]"," failed: { path: string, reason: string }[]","}","","async function chmodAll(workspaceRoot: string, mode: number): Promise<EnvLockResult> {"," const files = await findEnvFiles(workspaceRoot)"," const result: EnvLockResult = { total: files.length, ok: [], failed: [] }"," for (const f of files) {"," try {"," await fsp.chmod(f, mode)"," result.ok.push(f)"," }"," catch (err) {"," result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })"," }"," }"," return result","}","","/** Set every `.env*` file in the workspace to 0o444 (read-only). */","export async function lockEnvFiles(workspaceRoot: string): Promise<EnvLockResult> {"," return chmodAll(workspaceRoot, 0o444)","}","","/** Restore every `.env*` file in the workspace to 0o644 (read-write). */","export async function unlockEnvFiles(workspaceRoot: string): Promise<EnvLockResult> {"," return chmodAll(workspaceRoot, 0o644)","}"],
issueLock: ["import type { Issue } from '../gitea/types'","","export interface LockedReason {"," locked: boolean"," prerequisiteNumber?: number"," prerequisiteColumn?: string","}","","/**"," * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,"," * 与 webview 的 `isIssueLocked` 保持同一语义。"," */","export function evaluateLockedReason(issues: Issue[], issueNumber: number): LockedReason {"," const issue = issues.find(i => i.number === issueNumber)"," if (!issue || issue.prerequisite === undefined)"," return { locked: false }"," const prereq = issues.find(i => i.number === issue.prerequisite)"," if (!prereq || prereq.column === 'done')"," return { locked: false }"," return {"," locked: true,"," prerequisiteNumber: prereq.number,"," prerequisiteColumn: prereq.column,"," }","}"],
};
// 文件树示例:本仓库的真实目录,只展开示例文件所在的路径。M 标记是示例状态。
const TREE = [
{ depth: 1, type: "folder", name: "docs" },
{ depth: 1, type: "folder", name: "jetbrains" },
{ depth: 1, type: "folder", name: "tui" },
{ depth: 1, type: "folder", name: "vscode", open: true },
{ depth: 2, type: "folder", name: "prompts" },
{ depth: 2, type: "folder", name: "res" },
{ depth: 2, type: "folder", name: "src", open: true },
{ depth: 3, type: "folder", name: "auth" },
{ depth: 3, type: "folder", name: "cc" },
{ depth: 3, type: "folder", name: "figma" },
{ depth: 3, type: "folder", name: "files", open: true },
{ depth: 4, type: "file", name: "envLock.ts", file: "envLock" },
{ depth: 3, type: "folder", name: "git" },
{ depth: 3, type: "folder", name: "gitea" },
{ depth: 3, type: "folder", name: "issues" },
{ depth: 3, type: "folder", name: "panel", open: true },
{ depth: 4, type: "folder", name: "handlers" },
{ depth: 4, type: "file", name: "issueLock.test.ts" },
{ depth: 4, type: "file", name: "issueLock.ts", file: "issueLock" },
{ depth: 4, type: "file", name: "KanbanPanel.ts", git: "M" },
{ depth: 4, type: "file", name: "messages.ts" },
{ depth: 3, type: "file", name: "extension.ts" },
{ depth: 2, type: "folder", name: "webview-ui", open: true },
{ depth: 3, type: "folder", name: "src", open: true },
{ depth: 4, type: "folder", name: "components", open: true },
{ depth: 5, type: "file", name: "BottomTabs.tsx", git: "M" },
{ depth: 5, type: "file", name: "IssueCard.tsx" },
{ depth: 5, type: "file", name: "KanbanBoard.tsx" },
{ depth: 5, type: "file", name: "PrFilesPanel.tsx" },
{ depth: 4, type: "file", name: "App.tsx" },
{ depth: 2, type: "file", name: "CLAUDE.md" },
{ depth: 2, type: "file", name: "package.json" },
{ depth: 2, type: "file", name: "README.md" },
];
const EXT_LABEL = { ts: "TS", tsx: "TSX", json: "{}", md: "M↓" };
// 活动栏:VS Code 自带的几个 + Superpowers 的小火箭 + 新注册的「代码阅读」
const ACTIVITY = [
{ key: "explorer", icon: "files" },
{ key: "search", icon: "search" },
{ key: "scm", icon: "git-branch" },
{ key: "debug", icon: "play" },
{ key: "remote", icon: "monitor" },
{ key: "extensions", icon: "blocks" },
{ key: "superpowers", icon: "rocket" },
{ key: "reader", icon: "book-open-text" },
];
const FILES = {
envLock: { name: "envLock.ts", ext: "ts", crumbs: ["vscode", "src", "files", "envLock.ts"] },
issueLock: { name: "issueLock.ts", ext: "ts", crumbs: ["vscode", "src", "panel", "issueLock.ts"] },
};
// 代码块的种类:标签文字 + 颜色。同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),标签区分具体是哪种。
const BLOCK_KINDS = {
import: { label: "导入", color: "#c586c0" },
function: { label: "函数", color: "#dcdcaa" },
type: { label: "类型", color: "#4ec9b0" },
field: { label: "字段", color: "#9cdcfe" },
const: { label: "常量", color: "#9cdcfe" },
variable: { label: "变量", color: "#9cdcfe" },
if: { label: "条件", color: "#ce9178" },
loop: { label: "循环", color: "#8f9cff" },
try: { label: "尝试", color: "#f48771" },
catch: { label: "捕获", color: "#f48771" },
return: { label: "返回", color: "#89d185" },
statement: { label: "语句", color: "#9d9d9d" },
comment: { label: "注释", color: "#6a9955" },
};
// 代码块树。at:源码行号(单行,或 [起, 止]);kind:种类;kids:嵌套在它里面的块;
// edit:原型里默认停在「编辑」状态,用来展示两种样子。块里显示的译文由 translateBlock 按模板直译生成。
// 没落进任何块的行只能是空行或收尾括号,测试会检查。
const CODE_TREE = {
envLock: [
{ at: [1, 10], kind: "comment" },
{ at: 12, kind: "import" },
{ at: 13, kind: "import" },
{ at: 15, kind: "const" },
{ at: 16, kind: "const" },
{ at: [18, 22], kind: "comment" },
{ at: 23, kind: "function", kids: [
{ at: 24, kind: "variable" },
{ at: 25, kind: "if", kids: [
{ at: 26, kind: "return" },
] },
{ at: 27, kind: "variable", edit: true },
{ at: 28, kind: "variable" },
{ at: 29, kind: "return" },
] },
{ at: 32, kind: "type", kids: [
{ at: 33, kind: "field" },
{ at: 34, kind: "field" },
{ at: 35, kind: "field" },
] },
{ at: 38, kind: "function", kids: [
{ at: 39, kind: "variable" },
{ at: 40, kind: "variable" },
{ at: 41, kind: "loop", kids: [
{ at: 42, kind: "try", kids: [
{ at: 43, kind: "statement" },
{ at: 44, kind: "statement" },
] },
{ at: 46, kind: "catch", kids: [
{ at: 47, kind: "statement" },
] },
] },
{ at: 50, kind: "return" },
] },
{ at: 53, kind: "comment" },
{ at: 54, kind: "function", kids: [
{ at: 55, kind: "return" },
] },
{ at: 58, kind: "comment" },
{ at: 59, kind: "function", kids: [
{ at: 60, kind: "return" },
] },
],
issueLock: [
{ at: 1, kind: "import" },
{ at: 3, kind: "type", kids: [
{ at: 4, kind: "field" },
{ at: 5, kind: "field" },
{ at: 6, kind: "field" },
] },
{ at: [9, 12], kind: "comment" },
{ at: 13, kind: "function", kids: [
{ at: 14, kind: "variable" },
{ at: 15, kind: "if", edit: true, kids: [
{ at: 16, kind: "return" },
] },
{ at: 17, kind: "variable" },
{ at: 18, kind: "if", kids: [
{ at: 19, kind: "return" },
] },
{ at: [20, 24], kind: "return" },
] },
],
};
const MODES = ["edit", "preview"];
function lineRange(at) {
return Array.isArray(at) ? at : [at, at];
}
/** 一块对应的源码:取出行、去掉共同的缩进。 */
function blockCode(fileKey, at) {
const [from, to] = lineRange(at);
const lines = SOURCES[fileKey].slice(from - 1, to);
const indent = Math.min(...lines.filter((line) => line.trim()).map((line) => line.match(/^ */)[0].length));
return lines.map((line) => line.slice(indent)).join("\n");
}
function walkTree(nodes, visit, depth = 0) {
for (const node of nodes) {
visit(node, depth);
if (node.kids) walkTree(node.kids, visit, depth + 1);
}
}
// 模板直译(原型版,TypeScript):结构照抄源码,只把关键字、内置类型、运算符换成中文,每个词单独上色;
// 标识符、属性名(点号后面的词)和字面量原样保留;关键字和前后内容之间保留源码里的空格。
// 块头上包住子块的 { 去掉(嵌套已经由树表示),多行合成一行。
// 注释块去掉注释符号,原文照搬(模板翻不了自然语言)。
const WORD_ZH = {
import: ["导入", "tokenControl"], from: ["来自", "tokenControl"], as: ["作为", "tokenControl"], export: ["公共", "tokenControl"],
if: ["如果", "tokenControl"], else: ["否则", "tokenControl"], for: ["遍历", "tokenControl"], of: ["取自", "tokenControl"],
return: ["返回", "tokenControl"], try: ["尝试", "tokenControl"], catch: ["捕获", "tokenControl"], await: ["等待", "tokenControl"],
const: ["常量", "tokenStorage"], let: ["变量", "tokenStorage"], var: ["变量", "tokenStorage"], function: ["函数", "tokenStorage"],
async: ["异步", "tokenStorage"], interface: ["接口", "tokenStorage"], type: ["类型", "tokenStorage"], class: ["类", "tokenStorage"],
new: ["新建", "tokenStorage"], instanceof: ["属于", "tokenStorage"],
true: ["真", "tokenStorage"], false: ["假", "tokenStorage"], null: ["空", "tokenStorage"], undefined: ["未定义", "tokenStorage"],
string: ["字符串", "tokenType"], number: ["数值", "tokenType"], boolean: ["布尔", "tokenType"], void: ["无", "tokenType"],
};
const OPERATOR_ZH = { "===": "全等", "!==": "不全等", "&&": "与", "||": "或", "!": "非" };
function stripComment(code) {
return code.split("\n")
.map((line) => line.replace(/^\s*(\/\*\*?|\*\/|\*|\/\/)\s?/, "").replace(/\s*\*\/\s*$/, ""))
.filter((line) => line.trim())
.join(" ")
.trim();
}
/** 一块的译文和每个词的颜色。 */
function translateBlock(fileKey, node) {
const code = blockCode(fileKey, node.at);
if (node.kind === "comment") return { text: stripComment(code), ranges: [] };
const flat = code.replace(/\s*\n\s*/g, " ").replace(/,\s*}/g, " }").replace(/\s*\{$/, "");
const token = /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)|(===|!==|&&|\|\||!(?!=))|(\[\])/gi;
const ranges = [];
let text = "";
let last = 0;
const push = (value, color) => {
ranges.push({ start: text.length, end: text.length + value.length, color });
text += value;
};
let match;
while ((match = token.exec(flat))) {
const [whole, string, number, word, operator, emptyArray] = match;
text += flat.slice(last, match.index);
last = match.index + whole.length;
if (string) push(string, COLORS.tokenString);
else if (number) push(number, COLORS.tokenLiteral);
else if (word && flat[match.index - 1] !== "." && WORD_ZH[word]) push(WORD_ZH[word][0], COLORS[WORD_ZH[word][1]]);
else if (word && /^[A-Z]/.test(word)) push(word, COLORS.tokenType);
else if (word && /^\s*\(/.test(flat.slice(last, last + 4))) push(word, COLORS.tokenFunction);
else if (word) push(word, COLORS.tokenVariable);
else if (operator) {
push(OPERATOR_ZH[operator], COLORS.tokenStorage);
if (operator === "!") text += " ";
}
else if (/[\w\]}>)]/.test(flat[match.index - 1] || "")) text += emptyArray;
else push("空数组", COLORS.tokenLiteral);
}
return { text: text + flat.slice(last), ranges };
}
const CODE_CONTROL = new Set(["import", "from", "export", "if", "else", "for", "of", "in", "return", "try", "catch", "await", "throw", "while", "as"]);
const CODE_STORAGE = new Set(["const", "let", "var", "function", "async", "interface", "type", "class", "new", "instanceof", "typeof", "true", "false", "null", "undefined"]);
/**
* 原型用的简易上色,只为画出效果:注释、字符串、数字、关键字、大写开头当类型、后面跟括号当函数、其余当变量。
* 真实插件里源码的颜色直接来自语法树。
*/
function highlightCode(code) {
const token = /(\/\*[\s\S]*?\*\/|\/\/[^\n]*)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/gi;
const ranges = [];
let match;
while ((match = token.exec(code))) {
const [whole, comment, string, number, word] = match;
let color = COLORS.tokenVariable;
if (comment) color = COLORS.comment;
else if (string) color = COLORS.tokenString;
else if (number) color = COLORS.tokenLiteral;
else if (CODE_CONTROL.has(word)) color = COLORS.tokenControl;
else if (CODE_STORAGE.has(word)) color = COLORS.tokenStorage;
else if (/^[A-Z]/.test(word)) color = COLORS.tokenType;
else if (/^\s*\(/.test(code.slice(match.index + whole.length, match.index + whole.length + 4))) color = COLORS.tokenFunction;
ranges.push({ start: match.index, end: match.index + whole.length, color });
}
return ranges;
}
// ─── Small helpers ───────────────────────────────────────────────────────────
function nextPageName(pages) {
const names = new Set(pages.map((page) => page.name));
let name = PAGE_NAME;
for (let index = 2; names.has(name); index += 1) name = `${PAGE_NAME} ${index}`;
return name;
}
function rgb(hex) {
return { r: parseInt(hex.slice(1, 3), 16) / 255, g: parseInt(hex.slice(3, 5), 16) / 255, b: parseInt(hex.slice(5, 7), 16) / 255 };
}
function paint(color, opacity = 1) {
return [{ type: "SOLID", color: rgb(color), opacity }];
}
// Pick the first available CJK family; load only the styles that are used.
// Fail before any page is created when no CJK font exists. 等宽字体可选,缺了退回中文字体。
async function chooseFonts(api) {
const available = (await api.listAvailableFontsAsync()).map((entry) => entry.fontName);
const has = (font) => available.some((item) => item.family === font.family && item.style === font.style);
for (const family of FONT_FAMILIES) {
const styles = available.filter((font) => font.family === family);
const regular = styles.find((font) => ["Regular", "Normal", "Book"].includes(font.style));
if (!regular) continue;
const medium = styles.find((font) => ["Medium", "Semibold", "SemiBold"].includes(font.style)) || regular;
await api.loadFontAsync(regular);
if (medium !== regular) await api.loadFontAsync(medium);
const mono = MONO_FONTS.find(has) || regular;
if (mono !== regular) await api.loadFontAsync(mono);
return { regular, medium, mono };
}
throw new Error(`没有找到可用的中文字体。请在 Figma 中启用 ${FONT_FAMILIES.join("、")} 之一后重试。`);
}
/**
* Auto-layout container. With `width` that axis is fixed and the other hugs;
* without it both axes hug. o: dir / width / height / pad / px / py / gap /
* align (cross axis) / justify (main axis) / fill / fillOpacity / stroke / radius / component
*/
function frame(ctx, name, o = {}) {
const node = o.component ? ctx.api.createComponent() : ctx.api.createFrame();
node.name = name;
node.layoutMode = o.dir || "VERTICAL";
const horizontal = node.layoutMode === "HORIZONTAL";
node.fills = o.fill ? paint(o.fill, o.fillOpacity) : [];
node.strokes = o.stroke ? paint(o.stroke, o.strokeOpacity) : [];
node.strokeWeight = 1;
node.clipsContent = false;
node.cornerRadius = o.radius || 0;
node.paddingTop = node.paddingBottom = o.py ?? o.pad ?? 0;
node.paddingLeft = node.paddingRight = o.px ?? o.pad ?? 0;
node.itemSpacing = o.gap || 0;
node.counterAxisAlignItems = o.align || "MIN";
node.primaryAxisAlignItems = o.justify || "MIN";
if (o.width) {
node.resize(o.width, o.height || 1);
node.primaryAxisSizingMode = horizontal ? "FIXED" : o.height ? "FIXED" : "AUTO";
node.counterAxisSizingMode = horizontal ? (o.height ? "FIXED" : "AUTO") : "FIXED";
} else {
node.primaryAxisSizingMode = "AUTO";
node.counterAxisSizingMode = "AUTO";
}
return node;
}
function append(parent, node, fill = false) {
parent.appendChild(node);
if (fill) node.layoutSizingHorizontal = "FILL";
return node;
}
/** Frame that fills the parent's width, so its own children can fill too. */
function filled(ctx, parent, name, o = {}) {
return append(parent, frame(ctx, name, { ...o, width: 10 }), true);
}
/** 只画一条上边框(VS Code 标签页的高亮线、状态栏的分隔线)。 */
function topBorder(node, color) {
node.strokes = paint(color);
node.strokeTopWeight = 1;
node.strokeRightWeight = node.strokeBottomWeight = node.strokeLeftWeight = 0;
}
/**
* Text. Default: fills the parent width and grows in height. `hug`: both axes hug (labels, values).
* 不读父节点宽度:每读一次都会逼 Figma 把整棵自动布局树重排一遍。
*/
function text(ctx, parent, name, characters, o = {}) {
const node = ctx.api.createText();
node.name = name;
node.fontName = o.font || (o.mono && !CJK.test(characters) ? ctx.fonts.mono : o.medium ? ctx.fonts.medium : ctx.fonts.regular);
node.fontSize = o.size || 13;
node.lineHeight = { unit: "PIXELS", value: o.lineHeight || Math.round(node.fontSize * 1.5) };
node.characters = characters;
node.fills = paint(o.color || COLORS.fg);
if (o.align) node.textAlignHorizontal = o.align;
parent.appendChild(node);
if (o.hug) {
node.textAutoResize = "WIDTH_AND_HEIGHT";
return node;
}
node.textAutoResize = "HEIGHT";
node.layoutSizingHorizontal = "FILL";
if (o.singleLine) {
node.textTruncation = "ENDING";
node.maxLines = 1;
}
return node;
}
/** 等宽字体没有中文字形:连续的中文字符换成中文字体。 */
function useCjkFont(ctx, node) {
const run = /[\u3000-\u9fff\uff00-\uffef]+/g;
let match;
while ((match = run.exec(node.characters))) node.setRangeFontName(match.index, match.index + match[0].length, ctx.fonts.regular);
}
function paintRanges(node, ranges) {
for (const range of ranges) node.setRangeFills(range.start, range.end, paint(range.color));
}
function divider(ctx, parent, o = {}) {
const node = ctx.api.createFrame();
node.name = "Divider";
node.resize(o.vertical ? 1 : 10, o.vertical ? 10 : 1);
node.fills = paint(o.color || COLORS.border);
parent.appendChild(node);
if (o.vertical) node.layoutSizingVertical = "FILL";
else node.layoutSizingHorizontal = "FILL";
return node;
}
function dot(ctx, parent, name, color, size) {
const node = ctx.api.createEllipse();
node.name = name;
node.resize(size, size);
node.fills = paint(color);
parent.appendChild(node);
return node;
}
// Inline lucide paths. Add icons here; never fetch them.
const LUCIDE = {
"chevron-right": [["path", { d: "m9 18 6-6-6-6" }]],
"chevron-down": [["path", { d: "m6 9 6 6 6-6" }]],
"chevrons-down-up": [["path", { d: "m7 20 5-5 5 5" }], ["path", { d: "m7 4 5 5 5-5" }]],
"triangle-alert": [["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }], ["path", { d: "M12 9v4" }], ["path", { d: "M12 17h.01" }]],
"circle-x": [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "m15 9-6 6" }], ["path", { d: "m9 9 6 6" }]],
"eye": [["path", { d: "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
"pencil": [["path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }], ["path", { d: "m15 5 4 4" }]],
"refresh-cw": [["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }], ["path", { d: "M21 3v5h-5" }], ["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }], ["path", { d: "M8 16H3v5" }]],
"file-plus": [["path", { d: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" }], ["path", { d: "M14 2v4a2 2 0 0 0 2 2h4" }], ["path", { d: "M9 15h6" }], ["path", { d: "M12 18v-6" }]],
"folder-plus": [["path", { d: "M12 10v6" }], ["path", { d: "M9 13h6" }], ["path", { d: "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]],
"ellipsis": [["circle", { cx: "12", cy: "12", r: "1" }], ["circle", { cx: "19", cy: "12", r: "1" }], ["circle", { cx: "5", cy: "12", r: "1" }]],
"columns-2": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }], ["path", { d: "M12 3v18" }]],
"x": [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]],
"files": [["path", { d: "M20 7h-3a2 2 0 0 1-2-2V2" }], ["path", { d: "M9 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h7l4 4v10a2 2 0 0 1-2 2Z" }], ["path", { d: "M3 7.6v12.8A1.6 1.6 0 0 0 4.6 22h9.8" }]],
"search": [["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]],
"git-branch": [["path", { d: "M6 3v12" }], ["circle", { cx: "18", cy: "6", r: "3" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M18 9a9 9 0 0 1-9 9" }]],
"play": [["path", { d: "M6 3 20 12 6 21Z" }]],
"monitor": [["rect", { width: "20", height: "14", x: "2", y: "3", rx: "2" }], ["path", { d: "M8 21h8" }], ["path", { d: "M12 17v4" }]],
"blocks": [["rect", { width: "7", height: "7", x: "14", y: "3", rx: "1" }], ["path", { d: "M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3" }]],
"rocket": [["path", { d: "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" }], ["path", { d: "m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" }], ["path", { d: "M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" }], ["path", { d: "M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" }]],
"book-open-text": [["path", { d: "M12 7v14" }], ["path", { d: "M16 12h2" }], ["path", { d: "M16 8h2" }], ["path", { d: "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" }], ["path", { d: "M6 12h2" }], ["path", { d: "M6 8h2" }]],
"circle-user": [["circle", { cx: "12", cy: "12", r: "10" }], ["circle", { cx: "12", cy: "10", r: "3" }], ["path", { d: "M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662" }]],
"settings": [["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
};
function icon(ctx, name, color, size, o = {}) {
const shapes = LUCIDE[name];
if (!shapes) throw new Error(`未收录的图标: ${name}`);
const body = shapes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${value}"`).join(" ")}/>`).join("");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="${color}" stroke-width="${o.weight || 1.8}" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const node = ctx.api.createNodeFromSvg(svg);
node.name = o.name || `Icon / ${name}`;
return node;
}
function folderGlyph(ctx) {
const node = ctx.api.createNodeFromSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16"><path fill="${COLORS.folder}" d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.3l2 2h8.7A1.5 1.5 0 0 1 21 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z"/></svg>`);
node.name = "Icon / folder";
return node;
}
/** Combine components into a variant set and lay them out in a grid. */
function componentSet(ctx, page, name, components, columns) {
for (const component of components) page.appendChild(component);
const set = ctx.api.combineAsVariants(components, page);
set.name = name;
set.layoutMode = "NONE";
set.fills = paint(COLORS.side);
set.strokes = paint(COLORS.fgStrong, 0.12);
set.cornerRadius = 16;
const gap = 32;
const width = Math.max(...components.map((node) => node.width));
let y = gap;
for (let index = 0; index < components.length; index += columns) {
const row = components.slice(index, index + columns);
row.forEach((node, column) => { node.x = gap + column * (width + gap); node.y = y; });
y += Math.max(...row.map((node) => node.height)) + gap;
}
set.resize(columns * (width + gap) + gap, y);
return set;
}
/**
* Click wiring. NAVIGATE: only to a top-level frame on the page, never back
* into the source's own board (Figma rejects it). CHANGE_TO: only to a sibling variant.
*/
async function connect(source, destination, navigation) {
await source.setReactionsAsync([{
trigger: { type: "ON_CLICK" },
actions: [{ type: "NODE", destinationId: destination.id, navigation,
...(navigation === "NAVIGATE" ? { resetInteractiveComponents: true } : {}),
transition: { type: navigation === "CHANGE_TO" ? "SMART_ANIMATE" : "DISSOLVE",
duration: 0.16, easing: { type: "EASE_OUT" } } }],
}]);
}
function named(root, name) {
const node = root.findOne((item) => item.name === name);
if (!node) throw new Error(`找不到图层: ${name}`);
return node;
}
function setText(root, name, characters) {
named(root, name).characters = characters;
}
// ─── Components: 代码块 ──────────────────────────────────────────────────────
/**
* Reader / CodeBlock — Kind=<种类>, State=preview | edit。
* 左边折叠箭头(没有子块的块在实例里把箭头藏起来)、种类标签,中间是译文 / 源码,右边是切换按钮。
* 两种状态都带 Plain(译文)和 Code(源码)两个文字层,只切换显示哪个:实例里改过的两段文字切状态时都保留。
*/
const BLOCK_VARIANTS = Object.keys(BLOCK_KINDS).flatMap((kind) => ["preview", "edit"].map((state) => [kind, state]));
function codeBlock(ctx, kind, state) {
const spec = BLOCK_KINDS[kind];
const comment = kind === "comment";
const editing = state === "edit";
const node = frame(ctx, `Kind=${kind}, State=${state}`, { component: true, dir: "HORIZONTAL", width: TREE_W, py: 8, gap: 10, radius: 8,
fill: editing ? COLORS.side : spec.color, fillOpacity: editing ? 1 : 0.07,
stroke: editing ? COLORS.focus : spec.color, strokeOpacity: editing ? 1 : 0.35 });
node.paddingLeft = 8;
node.paddingRight = 10;
if (comment) node.dashPattern = [4, 3];
const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" }));
append(fold, icon(ctx, "chevron-down", COLORS.desc, 14, { name: "Fold icon" }));
const badge = append(node, frame(ctx, "Badge", { dir: "HORIZONTAL", width: 44, height: 20, radius: 4, align: "CENTER", justify: "CENTER",
fill: spec.color, fillOpacity: 0.18 }));
text(ctx, badge, "Kind", spec.label, { hug: true, medium: true, size: 11, lineHeight: 14, color: spec.color });
const content = append(node, frame(ctx, "Content", { width: 10 }), true);
const plain = text(ctx, content, "Plain", "导入 { xx } 来自 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: comment ? COLORS.desc : COLORS.fg });
useCjkFont(ctx, plain);
const code = text(ctx, content, "Code", "import { xx } from 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20,
color: comment ? COLORS.comment : COLORS.code });
plain.visible = !editing;
code.visible = editing;
const toggle = append(node, frame(ctx, "Toggle", { dir: "HORIZONTAL", px: 8, py: 2, gap: 4, radius: 4, align: "CENTER",
fill: editing ? COLORS.toggleOn : null }));
append(toggle, icon(ctx, editing ? "eye" : "pencil", COLORS.desc, 13));
text(ctx, toggle, "Label", editing ? "预览" : "编辑", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
return { node, toggle };
}
/** 一层代码块:左边一条竖线标出这一层,右边是这一层的块,块里嵌套的下一层紧跟在块下面。 */
function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed) {
const level = filled(ctx, parent, `Level ${depth}`, { dir: "HORIZONTAL", gap: 8 });
const guide = append(level, frame(ctx, "Guide", { width: 12, align: "CENTER" }));
guide.layoutSizingVertical = "FILL";
const line = ctx.api.createRectangle();
line.name = "Line";
line.resize(1, 10);
line.fills = paint(COLORS.guide);
guide.appendChild(line);
line.layoutSizingVertical = "FILL";
const list = append(level, frame(ctx, "Blocks", { width: 10, gap: 8 }), true);
for (const item of nodes) {
const instance = lib.block.get(`${item.kind}/${item.edit ? "edit" : "preview"}`).node.createInstance();
instance.name = `Block / L${lineRange(item.at)[0]}`;
append(list, instance, true);
const plain = named(instance, "Plain");
const translated = translateBlock(fileKey, item);
plain.characters = translated.text;
paintRanges(plain, translated.ranges);
useCjkFont(ctx, plain);
const code = named(instance, "Code");
code.characters = blockCode(fileKey, item.at);
paintRanges(code, highlightCode(code.characters));
useCjkFont(ctx, code);
if (!item.kids) named(instance, "Fold icon").visible = false;
placed.push({ item, instance, depth });
if (item.kids) blockLevel(ctx, list, lib, fileKey, item.kids, depth + 1, placed);
}
return level;
}
/** Reader / BlockTree — File=<文件>:预览模式下编辑区里的整棵代码块树 */
function blockTree(ctx, lib, fileKey) {
const node = frame(ctx, `File=${fileKey}`, { component: true, width: TREE_W, py: 20, fill: COLORS.editor });
const placed = [];
blockLevel(ctx, node, lib, fileKey, CODE_TREE[fileKey], 0, placed);
return { node, placed };
}
// ─── Components: VS Code 窗口外壳 ─────────────────────────────────────────────
/** Reader / ModeToggle — Mode=edit | preview:编辑器标题栏右上角的切换 */
function modeToggle(ctx, mode) {
const node = frame(ctx, `Mode=${mode}`, { component: true, dir: "HORIZONTAL", pad: 2, gap: 2, radius: 5, align: "CENTER",
fill: COLORS.field, stroke: COLORS.borderStrong });
for (const [key, label, iconName] of [["edit", "编辑", "pencil"], ["preview", "预览", "eye"]]) {
const on = key === mode;
const seg = append(node, frame(ctx, `Seg / ${label}`, { dir: "HORIZONTAL", px: 8, py: 2, gap: 4, radius: 3, align: "CENTER",
fill: on ? COLORS.toggleOn : null }));
append(seg, icon(ctx, iconName, on ? COLORS.fgStrong : COLORS.desc, 13));
text(ctx, seg, "Label", label, { hug: true, size: 12, lineHeight: 16, color: on ? COLORS.fgStrong : COLORS.desc });
}
return node;
}
/** Explorer / TreeRow — 文件夹展开 / 收起,文件默认 / 选中 / 有改动 / 有改动且选中。名字和扩展名在实例里改。 */
const ROW_VARIANTS = [
["folder", "closed"], ["folder", "open"],
["file", "default"], ["file", "selected"], ["file", "modified"], ["file", "modified-selected"],
];
function treeRow(ctx, type, state) {
const selected = state.endsWith("selected");
const modified = state.startsWith("modified");
const node = frame(ctx, `Type=${type}, State=${state}`, { component: true, dir: "HORIZONTAL", width: SIDEBAR_W, py: 3, gap: 6,
align: "CENTER", fill: selected ? COLORS.selection : null, stroke: selected ? COLORS.focus : null });
node.paddingLeft = 8;
node.paddingRight = 12;
const twisty = append(node, frame(ctx, "Twisty", { dir: "HORIZONTAL", width: 16, height: 16, align: "CENTER", justify: "CENTER" }));
if (type === "folder") {
append(twisty, icon(ctx, state === "open" ? "chevron-down" : "chevron-right", COLORS.fg, 16));
append(node, folderGlyph(ctx));
} else {
const ext = append(node, frame(ctx, "Ext", { dir: "HORIZONTAL", width: 16, height: 16, align: "CENTER", justify: "CENTER" }));
text(ctx, ext, "Ext label", "TS", { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS.ts });
}
text(ctx, node, "Name", type === "folder" ? "folder" : "file.ts", { size: 13, lineHeight: 16, singleLine: true,
color: modified ? COLORS.gitModified : COLORS.fg });
if (modified) text(ctx, node, "Git", "M", { hug: true, size: 12, lineHeight: 16, color: COLORS.gitModified });
return node;
}
/** Explorer / Header — 侧栏顶部:视图标题 + 新建文件 / 新建文件夹 / 刷新 / 全部折叠 */
function explorerHeader(ctx) {
const node = frame(ctx, "State=default", { component: true, dir: "HORIZONTAL", width: SIDEBAR_W, py: 9, gap: 8,
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
node.paddingLeft = 20;
node.paddingRight = 10;
text(ctx, node, "Title", "代码阅读", { hug: true, size: 11, lineHeight: 16, color: COLORS.fg });
const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" }));
for (const name of ["file-plus", "folder-plus", "refresh-cw", "chevrons-down-up", "ellipsis"]) append(actions, icon(ctx, name, COLORS.fg, 16));
return node;
}
/** Window / TitleBar — macOS 窗口标题栏,Title 在实例里改 */
function windowTitle(ctx) {
const node = frame(ctx, "State=active", { component: true, dir: "HORIZONTAL", width: WINDOW_W, px: 12, py: 8,
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
const lights = append(node, frame(ctx, "Lights", { dir: "HORIZONTAL", width: 60, gap: 8, align: "CENTER" }));
for (const [name, color] of [["Close", "#ff5f57"], ["Minimize", "#febc2e"], ["Zoom", "#28c840"]]) dot(ctx, lights, name, color, 12);
text(ctx, node, "Title", "superwork", { hug: true, size: 12, lineHeight: 14, color: COLORS.desc });
append(node, frame(ctx, "Balance", { dir: "HORIZONTAL", width: 60, height: 12 }));
return node;
}
/** Window / ActivityBar — Active=reader:新注册的「代码阅读」图标选中 */
function activityBar(ctx) {
const node = frame(ctx, "Active=reader", { component: true, width: ACTIVITY_W, height: MAIN_H, justify: "SPACE_BETWEEN", fill: COLORS.side });
const top = append(node, frame(ctx, "Top", { width: ACTIVITY_W }));
for (const item of ACTIVITY) {
const on = item.key === "reader";
const cell = append(top, frame(ctx, `Item / ${item.key}`, { dir: "HORIZONTAL", width: ACTIVITY_W, height: ACTIVITY_W, align: "CENTER", justify: "CENTER" }));
append(cell, icon(ctx, item.icon, on ? COLORS.activityOn : COLORS.activityIdle, 24, { weight: 1.5 }));
if (on) {
const bar = ctx.api.createRectangle();
bar.name = "Active bar";
cell.appendChild(bar);
bar.layoutPositioning = "ABSOLUTE";
bar.resize(2, ACTIVITY_W);
bar.x = 0;
bar.y = 0;
bar.fills = paint(COLORS.focus);
}
}
const bottom = append(node, frame(ctx, "Bottom", { width: ACTIVITY_W }));
for (const name of ["circle-user", "settings"]) {
const cell = append(bottom, frame(ctx, `Item / ${name}`, { dir: "HORIZONTAL", width: ACTIVITY_W, height: ACTIVITY_W, align: "CENTER", justify: "CENTER" }));
append(cell, icon(ctx, name, COLORS.activityIdle, 24, { weight: 1.5 }));
}
return node;
}
/** Window / StatusBar — Mode=edit | preview */
function statusBar(ctx, mode) {
const node = frame(ctx, `Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: WINDOW_W, px: 10, py: 3,
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
topBorder(node, COLORS.border);
const left = append(node, frame(ctx, "Left", { dir: "HORIZONTAL", gap: 12, align: "CENTER" }));
for (const [iconName, label] of [["git-branch", "master"], ["circle-x", "0"], ["triangle-alert", "0"]]) {
const item = append(left, frame(ctx, `Item / ${iconName}`, { dir: "HORIZONTAL", gap: 4, align: "CENTER" }));
append(item, icon(ctx, iconName, COLORS.fg, 14));
text(ctx, item, "Label", label, { hug: true, size: 12, lineHeight: 16 });
}
const right = append(node, frame(ctx, "Right", { dir: "HORIZONTAL", gap: 16, align: "CENTER" }));
const items = mode === "edit" ? ["行 23,列 1", "空格: 2", "UTF-8", "LF", "TypeScript"] : ["代码阅读"];
for (const label of items) text(ctx, right, `Item / ${label}`, label, { hug: true, size: 12, lineHeight: 16 });
return node;
}
/** Editor / TitleBar — File=<文件>, Mode=edit | preview:标签页 + 右上角的编辑 / 预览切换 */
function editorTitle(ctx, lib, fileKey, mode) {
const file = FILES[fileKey];
const node = frame(ctx, `File=${fileKey}, Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W,
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
const tab = append(node, frame(ctx, "Tab", { dir: "HORIZONTAL", px: 12, py: 9, gap: 6, align: "CENTER", fill: COLORS.editor }));
topBorder(tab, COLORS.focus);
text(ctx, tab, "Ext", EXT_LABEL[file.ext], { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS[file.ext] });
text(ctx, tab, "Name", file.name, { hug: true, size: 13, lineHeight: 17, color: COLORS.fgStrong });
append(tab, icon(ctx, "x", COLORS.fg, 14));
const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" }));
actions.paddingRight = 10;
const toggle = lib.toggle.get(mode).createInstance();
toggle.name = "Mode toggle";
actions.appendChild(toggle);
append(actions, icon(ctx, "columns-2", COLORS.fg, 16));
append(actions, icon(ctx, "ellipsis", COLORS.fg, 16));
return node;
}
/** Editor / Breadcrumb — File=<文件> */
function breadcrumb(ctx, fileKey) {
const file = FILES[fileKey];
const node = frame(ctx, `File=${fileKey}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, px: 16, py: 3, gap: 4,
align: "CENTER", fill: COLORS.editor });
file.crumbs.forEach((part, index) => {
text(ctx, node, `Crumb / ${part}`, part, { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
if (index < file.crumbs.length - 1) append(node, icon(ctx, "chevron-right", COLORS.desc, 12));
});
return node;
}
function createComponents(ctx, page) {
const sets = [];
const lib = {};
const make = (name, entries, columns, build, node = (item) => item) => {
const map = new Map(entries.map((entry) => [entry, build(entry)]));
sets.push(componentSet(ctx, page, name, [...map.values()].map(node), columns));
return map;
};
const fileKeys = Object.keys(FILES);
lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode));
lib.block = make("Reader / CodeBlock", BLOCK_VARIANTS.map(([kind, state]) => `${kind}/${state}`), 2,
(entry) => codeBlock(ctx, ...entry.split("/")), (item) => item.node);
lib.row = make("Explorer / TreeRow", ROW_VARIANTS.map(([type, state]) => `${type}/${state}`), 2,
(entry) => treeRow(ctx, ...entry.split("/")));
lib.explorerHeader = make("Explorer / Header", ["default"], 1, () => explorerHeader(ctx));
lib.windowTitle = make("Window / TitleBar", ["active"], 1, () => windowTitle(ctx));
lib.activity = make("Window / ActivityBar", ["reader"], 1, () => activityBar(ctx));
lib.status = make("Window / StatusBar", MODES, 1, (mode) => statusBar(ctx, mode));
lib.editorTitle = make("Editor / TitleBar", fileKeys.flatMap((fileKey) => MODES.map((mode) => `${fileKey}/${mode}`)), 2,
(entry) => editorTitle(ctx, lib, ...entry.split("/")));
lib.breadcrumb = make("Editor / Breadcrumb", fileKeys, 1, (fileKey) => breadcrumb(ctx, fileKey));
lib.tree = make("Reader / BlockTree", fileKeys, 2, (fileKey) => blockTree(ctx, lib, fileKey), (item) => item.node);
return { lib, sets };
}
// ─── Boards ──────────────────────────────────────────────────────────────────
function rowVariant(item, selected) {
if (item.type === "folder") return `folder/${item.open ? "open" : "closed"}`;
const isSelected = item.file === selected;
if (item.git) return isSelected ? "file/modified-selected" : "file/modified";
return isSelected ? "file/selected" : "file/default";
}
/** 侧栏:「代码阅读」视图,外观照搬 VS Code 资源管理器。 */
function sidebar(ctx, parent, lib, selected) {
const pane = append(parent, frame(ctx, "Sidebar", { width: SIDEBAR_W, fill: COLORS.side }));
pane.layoutSizingVertical = "FILL";
pane.clipsContent = true;
pane.overflowDirection = "VERTICAL";
const header = lib.explorerHeader.get("default").createInstance();
header.name = "Header";
append(pane, header, true);
const root = filled(ctx, pane, "Root", { dir: "HORIZONTAL", py: 3, gap: 6, align: "CENTER" });
root.paddingLeft = 8;
append(root, icon(ctx, "chevron-down", COLORS.fg, 16));
text(ctx, root, "Name", "superwork", { hug: true, medium: true, size: 13, lineHeight: 16, color: COLORS.fgStrong });
const rows = new Map();
for (const item of TREE) {
const instance = lib.row.get(rowVariant(item, selected)).createInstance();
instance.name = `Row / ${item.name}`;
append(pane, instance, true);
instance.paddingLeft = 8 + item.depth * 14;
setText(instance, "Name", item.name);
if (item.type === "file") {
const ext = item.name.split(".").pop();
const label = named(instance, "Ext label");
label.characters = EXT_LABEL[ext] || "";
label.fills = paint(COLORS[ext] || COLORS.desc);
}
if (item.file) rows.set(item.file, instance);
}
return { pane, rows };
}
/** 编辑模式:VS Code 原生编辑器,整份源码。 */
function codeView(ctx, editor, fileKey) {
const view = filled(ctx, editor, "Code view", { dir: "HORIZONTAL", py: 4, gap: 22 });
view.layoutSizingVertical = "FILL";
view.clipsContent = true;
view.overflowDirection = "VERTICAL";
const lines = SOURCES[fileKey].map((line) => line || " ");
const numbers = append(view, frame(ctx, "Line numbers", { width: 48 }));
text(ctx, numbers, "No", lines.map((_, index) => String(index + 1)).join("\n"), { size: 13, lineHeight: 19, mono: true, align: "RIGHT", color: COLORS.dim });
const source = text(ctx, view, "Source", lines.join("\n"), { hug: true, font: ctx.fonts.mono, size: 13, lineHeight: 19, color: COLORS.code });
paintRanges(source, highlightCode(source.characters));
useCjkFont(ctx, source);
return view;
}
/** 预览模式:编辑区换成代码块树。 */
function previewView(ctx, editor, lib, fileKey, editorWidth) {
const view = filled(ctx, editor, "Preview view", { align: "CENTER", px: VIEW_PAD });
view.layoutSizingVertical = "FILL";
view.clipsContent = true;
view.overflowDirection = "VERTICAL";
const tree = lib.tree.get(fileKey).node.createInstance();
tree.name = `Block tree / ${FILES[fileKey].name}`;
append(view, tree, editorWidth - 2 * VIEW_PAD < TREE_W);
return view;
}
/** 一整个 VS Code 窗口:标题栏 / 活动栏 + 侧栏 + 编辑器 / 状态栏。 */
function windowBoard(ctx, page, lib, name, o) {
const width = o.width || WINDOW_W;
const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1;
const screen = frame(ctx, `Prototype / ${name}`, { width, height: WINDOW_H, fill: COLORS.editor });
screen.clipsContent = true;
page.appendChild(screen);
const title = lib.windowTitle.get("active").createInstance();
title.name = "Title bar";
append(screen, title, true);
setText(title, "Title", `${FILES[o.file].name} — superwork`);
const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width, height: MAIN_H }));
const activity = lib.activity.get("reader").createInstance();
activity.name = "Activity bar";
main.appendChild(activity);
activity.layoutSizingVertical = "FILL";
const side = sidebar(ctx, main, lib, o.file);
divider(ctx, main, { vertical: true });
const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor }));
editor.layoutSizingVertical = "FILL";
const titleBar = lib.editorTitle.get(`${o.file}/${o.mode}`).createInstance();
titleBar.name = "Editor title";
append(editor, titleBar, true);
const crumbs = lib.breadcrumb.get(o.file).createInstance();
crumbs.name = "Breadcrumb";
append(editor, crumbs, true);
const view = o.mode === "edit" ? codeView(ctx, editor, o.file) : previewView(ctx, editor, lib, o.file, editorWidth);
const status = lib.status.get(o.mode).createInstance();
status.name = "Status bar";
append(screen, status, true);
return { screen, rows: side.rows, titleBar, view };
}
// ─── Entry ───────────────────────────────────────────────────────────────────
async function build(api, stage = () => {}) {
stage("加载中文字体");
const ctx = { api, fonts: await chooseFonts(api) };
stage("新建独立页面");
const name = nextPageName(api.root.children);
const page = api.createPage();
page.name = name;
await api.setCurrentPageAsync(page);
page.backgrounds = paint("#111214");
page.setSharedPluginData(NAMESPACE, "generator", GENERATOR);
stage("生成组件");
const components = createComponents(ctx, page);
const { lib } = components;
stage("生成演示画板");
const boards = {};
for (const [fileKey, file] of Object.entries(FILES)) {
boards[fileKey] = {
edit: windowBoard(ctx, page, lib, `${file.name} · 编辑`, { file: fileKey, mode: "edit" }),
preview: windowBoard(ctx, page, lib, `${file.name} · 预览`, { file: fileKey, mode: "preview" }),
};
}
const narrow = windowBoard(ctx, page, lib, "envLock.ts · 预览(编辑区变窄)", { file: "envLock", mode: "preview", width: WINDOW_NARROW });
stage("连接演示入口");
// 代码块:「编辑」换成源码,「预览」换回译文
for (const kind of Object.keys(BLOCK_KINDS)) {
const preview = lib.block.get(`${kind}/preview`);
const edit = lib.block.get(`${kind}/edit`);
await connect(preview.toggle, edit.node, "CHANGE_TO");
await connect(edit.toggle, preview.node, "CHANGE_TO");
}
// 窗口之间:右上角切编辑 / 预览;侧栏点别的文件就用编辑器打开它
for (const [fileKey, pair] of Object.entries(boards)) {
await connect(named(pair.edit.titleBar, "Seg / 预览"), pair.preview.screen, "NAVIGATE");
await connect(named(pair.preview.titleBar, "Seg / 编辑"), pair.edit.screen, "NAVIGATE");
for (const view of [pair.edit, pair.preview]) {
for (const [other, target] of Object.entries(boards)) {
if (other !== fileKey) await connect(view.rows.get(other), target.edit.screen, "NAVIGATE");
}
}
}
stage("排布画板");
const screens = [...Object.values(boards).flatMap((pair) => [pair.edit.screen, pair.preview.screen]), narrow.screen];
let y = 0;
for (const screen of screens) {
screen.x = 0;
screen.y = y;
y += screen.height + 120;
}
let setY = 0;
for (const set of components.sets) {
set.x = WINDOW_W + 160;
set.y = setY;
setY += set.height + 64;
}
const start = boards.envLock.preview.screen;
page.flowStartingPoints = [{ nodeId: start.id, name: "预览 envLock.ts" }];
page.selection = [start];
api.viewport.scrollAndZoomIntoView(page.selection);
return { page, components, boards, narrow, screens };
}
async function run(api) {
let currentStage = "初始化";
const notice = api.notify("正在生成原型,请稍候…", { timeout: Infinity });
await new Promise((resolve) => setTimeout(resolve, 50));
try {
await build(api, (stage) => { currentStage = stage; });
notice.cancel();
api.closePlugin("已生成「代码阅读」的组件与演示画板;选中「Prototype / envLock.ts · 预览」后点演示。");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
notice.cancel();
api.closePlugin(`生成失败(${currentStage}):${message}。已有设计未删除;本次未完成页面保留供检查。`);
}
}
if (typeof module !== "undefined") {
module.exports = { build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode };
}
if (typeof figma !== "undefined") void run(figma);
@@ -0,0 +1,10 @@
{
"name": "代码阅读器 Design Lab",
"api": "1.0.0",
"main": "code.js",
"editorType": ["figma"],
"documentAccess": "dynamic-page",
"networkAccess": {
"allowedDomains": ["none"]
}
}
+257
View File
@@ -0,0 +1,257 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode } = require("./code.js");
const { fakeFigma } = require("../../figma-lab/fake-figma");
function action(node) { return node.reactions[0].actions[0]; }
function overlaps(a, b) {
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
}
function named(root, name) { return root.findOne((node) => node.name === name); }
function countBlocks(nodes) {
let count = 0;
walkTree(nodes, () => { count += 1; });
return count;
}
// 代码块:13 种各「编辑 ↔ 预览」2 条 = 26
// 窗口:2 个文件 × 编辑 / 预览各 1 个切换 = 4;4 个窗口里侧栏点另一个文件 = 4;合计 34
const EXPECTED_LINKS = 34;
const EXPECTED_SETS = [
["Reader / ModeToggle", 2],
["Reader / CodeBlock", 26],
["Explorer / TreeRow", 6],
["Explorer / Header", 1],
["Window / TitleBar", 1],
["Window / ActivityBar", 1],
["Window / StatusBar", 2],
["Editor / TitleBar", 4],
["Editor / Breadcrumb", 2],
["Reader / BlockTree", 2],
];
test("manifest stays local, without a fabricated plugin ID", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "manifest.json"), "utf8"));
assert.equal(manifest.main, "code.js");
assert.equal(manifest.api, "1.0.0");
assert.equal(manifest.documentAccess, "dynamic-page");
assert.deepEqual(manifest.editorType, ["figma"]);
assert.deepEqual(manifest.networkAccess.allowedDomains, ["none"]);
assert.equal("id" in manifest, false);
});
test("code block trees cover every meaningful source line exactly once", () => {
assert.equal(SOURCES.envLock.length, 61);
assert.equal(SOURCES.issueLock.length, 25);
for (const [fileKey, nodes] of Object.entries(CODE_TREE)) {
const owner = new Map();
walkTree(nodes, (node) => {
const [from, to] = lineRange(node.at);
assert.ok(from <= to && to <= SOURCES[fileKey].length, `${fileKey} L${from}-${to} in range`);
assert.ok(blockCode(fileKey, node.at).trim(), `${fileKey} L${from} has code`);
assert.ok(BLOCK_KINDS[node.kind], `${fileKey} L${from} kind ${node.kind}`);
for (let line = from; line <= to; line += 1) {
assert.equal(owner.has(line), false, `${fileKey} L${line} belongs to one block`);
owner.set(line, node);
}
});
SOURCES[fileKey].forEach((line, index) => {
if (!owner.has(index + 1)) assert.match(line.trim(), /^[}\])]*$/, `${fileKey} L${index + 1} left out: ${line}`);
});
}
// 子块在源码里落在父块之后、兄弟块之前,树的顺序就是阅读顺序
for (const nodes of Object.values(CODE_TREE)) {
let last = 0;
walkTree(nodes, (node) => {
const [from] = lineRange(node.at);
assert.ok(from > last, `L${from} follows L${last}`);
last = lineRange(node.at)[1];
});
}
assert.equal(blockCode("envLock", 25), "if (!folder)");
assert.equal(blockCode("envLock", 46), "catch (err) {");
assert.equal(blockCode("issueLock", [20, 24]).split("\n")[1], " locked: true,");
});
test("template translation keeps the source structure and only translates words", () => {
const at = (fileKey, line) => {
let found;
walkTree(CODE_TREE[fileKey], (node) => { if (lineRange(node.at)[0] === line) found = node; });
return translateBlock(fileKey, found);
};
assert.equal(at("envLock", 23).text, "公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>");
assert.equal(at("envLock", 38).text, "异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise<EnvLockResult>");
assert.equal(at("envLock", 12).text, "导入 { promises 作为 fsp } 来自 'node:fs'");
assert.equal(at("envLock", 25).text, "如果 (非 folder)");
assert.equal(at("envLock", 26).text, "返回 空数组");
assert.equal(at("envLock", 41).text, "遍历 (常量 f 取自 files)");
assert.equal(at("envLock", 35).text, "failed: { path: 字符串, reason: 字符串 }[]");
assert.equal(at("envLock", 53).text, "Set every `.env*` file in the workspace to 0o444 (read-only).");
assert.equal(at("issueLock", 14).text, "常量 issue = issues.find(i => i.number 全等 issueNumber)");
assert.equal(at("issueLock", 20).text, "返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }");
// 每个词单独上色,关键字不和前后的字粘在一起
const signature = at("envLock", 23);
const words = signature.ranges.map((range) => [signature.text.slice(range.start, range.end), range.color]);
assert.deepEqual(words.slice(0, 4), [["公共", "#c586c0"], ["异步", "#569cd6"], ["函数", "#569cd6"], ["findEnvFiles", "#dcdcaa"]]);
for (const [fileKey, nodes] of Object.entries(CODE_TREE)) {
walkTree(nodes, (node) => {
if (node.kind === "comment") return;
const { text, ranges } = translateBlock(fileKey, node);
assert.ok(ranges.length > 0, `${fileKey} L${lineRange(node.at)[0]} has colored words`);
for (const range of ranges) {
for (const neighbor of [text[range.start - 1], text[range.end]]) {
assert.doesNotMatch(neighbor || " ", /[\u4e00-\u9fff\w]/, `${fileKey} "${text}" keeps "${text.slice(range.start, range.end)}" apart`);
}
}
});
}
const code = "const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)";
const colorOf = (word) => highlightCode(code).find((range) => code.slice(range.start, range.end) === word).color;
assert.equal(colorOf("const"), "#569cd6");
assert.equal(colorOf("await"), "#c586c0");
assert.equal(colorOf("findFiles"), "#dcdcaa");
assert.equal(colorOf("EXCLUDE_PATTERN"), "#4ec9b0");
assert.equal(colorOf("uris"), "#9cdcfe");
});
test("sample files appear in the sidebar tree", () => {
for (const key of Object.keys(FILES)) assert.equal(TREE.filter((row) => row.file === key).length, 1);
assert.deepEqual(Object.keys(CODE_TREE), Object.keys(FILES));
});
test("build creates the variant sets, reuses them on boards and wires the flows", async () => {
const api = fakeFigma();
const result = await build(api);
assert.equal(result.page.name, PAGE_NAME);
assert.notEqual(result.page.getSharedPluginData(NAMESPACE, "generator"), "");
assert.equal(api.connected, EXPECTED_LINKS);
const sets = result.page.children.filter((node) => node.type === "COMPONENT_SET");
assert.deepEqual(sets.map((set) => [set.name, set.children.length]), EXPECTED_SETS);
// 代码块:预览显示大白话,编辑显示源码;按钮在两种状态间切换
const { lib } = result.components;
for (const kind of Object.keys(BLOCK_KINDS)) {
const preview = lib.block.get(`${kind}/preview`);
const edit = lib.block.get(`${kind}/edit`);
assert.equal(named(preview.node, "Plain").visible, true);
assert.equal(named(preview.node, "Code").visible, false);
assert.equal(named(edit.node, "Plain").visible, false);
assert.equal(named(edit.node, "Code").visible, true);
assert.equal(action(preview.toggle).navigation, "CHANGE_TO");
assert.equal(action(preview.toggle).destinationId, edit.node.id);
assert.equal(action(edit.toggle).destinationId, preview.node.id);
}
// 代码块树:块数和数据一致,嵌套一层多一条竖线,默认「编辑」的块用编辑状态
for (const [fileKey, nodes] of Object.entries(CODE_TREE)) {
const { node, placed } = lib.tree.get(fileKey);
assert.equal(placed.length, countBlocks(nodes));
assert.equal(node.findAll((child) => child.type === "INSTANCE").length, placed.length);
const maxDepth = Math.max(...placed.map((entry) => entry.depth));
assert.equal(node.findAll((child) => child.name === "Guide").length, 1 + placed.filter((entry) => entry.item.kids).length);
assert.ok(maxDepth >= 2, `${fileKey} nests at least three levels`);
for (const { item, instance } of placed) {
const variant = lib.block.get(`${item.kind}/${item.edit ? "edit" : "preview"}`).node;
assert.equal(instance.mainComponentId, variant.id);
const parsed = translateBlock(fileKey, item);
assert.equal(named(instance, "Plain").characters, parsed.text);
assert.equal(named(instance, "Plain").rangeFills.length, parsed.ranges.length);
assert.equal(named(instance, "Code").characters, blockCode(fileKey, item.at));
assert.ok(named(instance, "Code").rangeFills.length > 0, "code is highlighted");
assert.equal(named(instance, "Fold icon").visible, Boolean(item.kids), "only blocks with children show the fold arrow");
assert.equal(named(instance, "Kind").characters, BLOCK_KINDS[item.kind].label);
}
}
const issueComment = lib.tree.get("issueLock").placed.find(({ item }) => item.kind === "comment").instance;
assert.ok(named(issueComment, "Code").rangeFonts.length >= 2, "Chinese comment lines use the CJK font");
const { envLock, issueLock } = result.boards;
const toPreview = action(named(envLock.edit.titleBar, "Seg / 预览"));
assert.equal(toPreview.navigation, "NAVIGATE");
assert.equal(toPreview.destinationId, envLock.preview.screen.id);
assert.equal(toPreview.resetInteractiveComponents, true);
assert.equal(action(named(envLock.preview.titleBar, "Seg / 编辑")).destinationId, envLock.edit.screen.id);
assert.equal(action(named(issueLock.edit.titleBar, "Seg / 预览")).destinationId, issueLock.preview.screen.id);
// 侧栏点文件 = 用编辑器打开它
assert.equal(action(envLock.edit.rows.get("issueLock")).destinationId, issueLock.edit.screen.id);
assert.equal(action(envLock.preview.rows.get("issueLock")).destinationId, issueLock.edit.screen.id);
assert.equal(action(issueLock.preview.rows.get("envLock")).destinationId, envLock.edit.screen.id);
assert.equal(envLock.edit.rows.get("envLock").reactions.length, 0);
assert.equal(result.narrow.screen.findAll((node) => node.reactions.length > 0).length, 0);
const setIds = new Set(sets.flatMap((set) => set.children.map((component) => component.id)));
for (const screen of result.screens) {
const instances = screen.findAll((node) => node.type === "INSTANCE");
assert.ok(instances.length > 0, `${screen.name} has instances`);
for (const instance of instances) assert.ok(setIds.has(instance.mainComponentId), `${instance.name} comes from a set`);
}
// 编辑窗口显示整份源码,预览窗口显示代码块树
assert.equal(named(envLock.edit.view, "Source").characters.split("\n").length, 61);
assert.equal(named(issueLock.edit.view, "Source").characters.split("\n").length, 25);
assert.ok(named(issueLock.edit.view, "Source").rangeFonts.length >= 2);
assert.equal(named(envLock.preview.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id);
assert.equal(named(result.narrow.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id);
assert.equal(named(issueLock.preview.view, "Block tree / issueLock.ts").mainComponentId, lib.tree.get("issueLock").node.id);
});
test("boards and sets sit on the page without overlapping", async () => {
const api = fakeFigma();
const result = await build(api);
const blocks = [...result.screens, ...result.components.sets];
for (const block of blocks) assert.equal(block.parent, result.page);
for (let i = 0; i < blocks.length; i += 1) {
for (let j = i + 1; j < blocks.length; j += 1) {
assert.equal(overlaps(blocks[i], blocks[j]), false, `${blocks[i].name} overlaps ${blocks[j].name}`);
}
}
assert.equal(result.page.flowStartingPoints[0].nodeId, result.boards.envLock.preview.screen.id);
});
test("the plugin stays small enough for Figma to build quickly", async () => {
const api = fakeFigma();
const result = await build(api);
let owned = 0;
const walk = (node) => {
owned += 1;
if (node.type !== "INSTANCE") node.children.forEach(walk);
};
result.page.children.forEach(walk);
assert.ok(owned < 1500, `page owns ${owned} layers outside instances`);
});
test("running twice numbers the new page and keeps the old one intact", async () => {
const api = fakeFigma();
const first = await build(api);
const title = named(first.boards.envLock.edit.screen, "Name");
const before = title.characters;
title.characters = "用户改过的标题";
const second = await build(api);
assert.equal(second.page.name, `${PAGE_NAME} 2`);
assert.equal(title.characters, "用户改过的标题");
assert.notEqual(before, "用户改过的标题");
assert.equal(nextPageName(api.root.children), `${PAGE_NAME} 3`);
});
test("missing CJK font fails before any page is created", async () => {
const api = fakeFigma({ fonts: [{ family: "Inter", style: "Regular" }] });
const pages = api.root.children.length;
await run(api);
assert.equal(api.root.children.length, pages);
assert.match(api.messages[0], /加载中文字体/);
});
test("page limit and rejected reactions surface the failing stage", async () => {
const limited = fakeFigma({ rejectPages: true });
await run(limited);
assert.match(limited.messages[0], /新建独立页面/);
const rejected = fakeFigma({ rejectReactions: true });
await run(rejected);
assert.match(rejected.messages[0], /连接演示入口/);
});
+311
View File
@@ -0,0 +1,311 @@
const assert = require("node:assert/strict");
const LAYOUT_KEYS = [
"layoutMode", "layoutPositioning", "visible",
"primaryAxisSizingMode", "counterAxisSizingMode",
"paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing",
"fontSize", "lineHeight", "textAutoResize", "maxLines",
];
const SKIP_COPY = new Set(["id", "parent", "children", "_rev", "_cachedRev", "_val", "_mark", "_flowRev", "_flowCache"]);
// 模拟 Figma 文档树与自动排版的尺寸推算, 检查结构、变体配对、实例复用与连线;
// 不模拟真实字体度量、绘制和演示引擎.
// 尺寸记在节点上. 改一个节点只作废它、祖先、以及祖先底下靠父尺寸吃饭的 FILL 孩子.
function fakeFigma(options = {}) {
let sequence = 0;
let stamp = 0;
const nodes = new Map();
const loaded = new Set();
const mark = (node, token) => {
if (!node || node._mark === token) return;
node._mark = token;
node._rev += 1;
for (const child of node.children) {
if (child._horizontal === "FILL" || child._vertical === "FILL") mark(child, token);
}
mark(node.parent, token);
};
const dirty = (node) => { mark(node, ++stamp); };
const fontKey = (font) => `${font.family}/${font.style}`;
const cjk = /[ -鿿＀-￯]/;
const textWidth = (node) => {
let width = 0;
for (const char of node._characters) width += cjk.test(char) ? node.fontSize : node.fontSize * 0.55;
return Math.max(1, Math.round(width));
};
const padX = (node) => node.paddingLeft + node.paddingRight;
const padY = (node) => node.paddingTop + node.paddingBottom;
const flow = (node) => {
if (node._flowRev === node._rev) return node._flowCache;
node._flowCache = node.children.filter((child) => child.visible && child.layoutPositioning !== "ABSOLUTE");
node._flowRev = node._rev;
return node._flowCache;
};
const gaps = (node) => Math.max(0, flow(node).length - 1) * node.itemSpacing;
class Node {
constructor(type) {
this.id = String(++sequence);
this.type = type;
this.name = type;
this.children = [];
this.parent = null;
this.visible = true;
this.layoutMode = "NONE";
this.layoutPositioning = "AUTO";
this.primaryAxisSizingMode = this.counterAxisSizingMode = "FIXED";
this.paddingLeft = this.paddingRight = this.paddingTop = this.paddingBottom = 0;
this.itemSpacing = 0;
this.x = this.y = 0;
this._width = this._height = 100;
this._characters = "";
this._horizontal = this._vertical = "FIXED";
this.fontSize = 14;
this.lineHeight = { unit: "PIXELS", value: 20 };
this.textAutoResize = "NONE";
this.maxLines = undefined;
this.data = {};
this.reactions = [];
this.rangeFills = [];
this.rangeFonts = [];
this.flowStartingPoints = [];
this._rev = 0;
this._mark = 0;
this._cachedRev = { w: -1, h: -1, iw: -1, ih: -1, mw: -1, mh: -1 };
this._val = { w: 0, h: 0, iw: 0, ih: 0, mw: 0, mh: 0 };
this._flowRev = -1;
this._flowCache = [];
for (const key of LAYOUT_KEYS) {
let value = this[key];
Object.defineProperty(this, key, {
get() { return value; },
set(next) { if (value !== next) { value = next; dirty(this); } },
enumerable: true,
configurable: true,
});
}
nodes.set(this.id, this);
}
_memo(slot, compute) {
if (this._cachedRev[slot] === this._rev) return this._val[slot];
this._cachedRev[slot] = this._rev;
const value = compute();
this._val[slot] = value;
api.layoutComputes += 1;
return value;
}
get intrinsicWidth() {
return this._memo("iw", () => {
if (this.type === "TEXT" && this.textAutoResize === "WIDTH_AND_HEIGHT") return textWidth(this);
if (this.layoutMode === "HORIZONTAL" && this.primaryAxisSizingMode === "AUTO") {
return padX(this) + flow(this).reduce((sum, child) => sum + child.measuredWidth, 0) + gaps(this);
}
if (this.layoutMode === "VERTICAL" && this.counterAxisSizingMode === "AUTO") {
return padX(this) + Math.max(0, ...flow(this).map((child) => child.measuredWidth));
}
return this._width;
});
}
get measuredWidth() {
return this._memo("mw", () => (this._horizontal === "FILL" ? this.intrinsicWidth : this.width));
}
get width() {
return this._memo("w", () => {
if (this._horizontal !== "FILL" || !this.parent) return this.intrinsicWidth;
const parent = this.parent;
const fixed = parent.layoutMode === "HORIZONTAL" ? parent.primaryAxisSizingMode : parent.counterAxisSizingMode;
assert.equal(fixed, "FIXED", `FILL width needs a fixed-width parent: ${this.name} in ${parent.name}`);
const available = parent.width - padX(parent);
if (parent.layoutMode === "VERTICAL") return available;
const siblings = flow(parent);
const fills = siblings.filter((child) => child._horizontal === "FILL");
const taken = siblings.filter((child) => child._horizontal !== "FILL").reduce((sum, child) => sum + child.width, 0);
return (available - taken - gaps(parent)) / fills.length;
});
}
get intrinsicHeight() {
return this._memo("ih", () => {
if (this.type === "TEXT") {
if (this.textAutoResize === "WIDTH_AND_HEIGHT") return this.lineHeight.value;
if (this.textAutoResize === "HEIGHT") {
const lines = Math.max(1, Math.ceil(textWidth(this) / Math.max(1, this.width)));
return Math.min(lines, this.maxLines || Infinity) * this.lineHeight.value;
}
}
if (this.layoutMode === "VERTICAL" && this.primaryAxisSizingMode === "AUTO") {
return padY(this) + flow(this).reduce((sum, child) => sum + child.measuredHeight, 0) + gaps(this);
}
if (this.layoutMode === "HORIZONTAL" && this.counterAxisSizingMode === "AUTO") {
return padY(this) + Math.max(0, ...flow(this).map((child) => child.measuredHeight));
}
return this._height;
});
}
get measuredHeight() {
return this._memo("mh", () => (this._vertical === "FILL" ? this.intrinsicHeight : this.height));
}
get height() {
return this._memo("h", () => {
if (this._vertical !== "FILL" || !this.parent) return this.intrinsicHeight;
const parent = this.parent;
const available = parent.height - padY(parent);
if (parent.layoutMode === "HORIZONTAL") return available;
const siblings = flow(parent);
const fills = siblings.filter((child) => child._vertical === "FILL");
const taken = siblings.filter((child) => child._vertical !== "FILL").reduce((sum, child) => sum + child.height, 0);
return (available - taken - gaps(parent)) / fills.length;
});
}
set layoutSizingHorizontal(value) {
if (value === "FILL") assert.ok(["HORIZONTAL", "VERTICAL"].includes(this.parent?.layoutMode), `FILL needs an auto-layout parent: ${this.name}`);
if (this._horizontal !== value) { this._horizontal = value; dirty(this); }
}
get layoutSizingHorizontal() { return this._horizontal; }
set layoutSizingVertical(value) {
if (value === "FILL") assert.ok(["HORIZONTAL", "VERTICAL"].includes(this.parent?.layoutMode), `FILL needs an auto-layout parent: ${this.name}`);
if (this._vertical !== value) { this._vertical = value; dirty(this); }
}
get layoutSizingVertical() { return this._vertical; }
set characters(value) {
assert.ok(this.fontName && loaded.has(fontKey(this.fontName)), `Load font before changing text: ${this.name}`);
if (this._characters !== value) { this._characters = value; dirty(this); }
}
get characters() { return this._characters; }
setRangeFills(start, end, fills) {
assert.ok(this.type === "TEXT" && start >= 0 && start < end && end <= this._characters.length, `Text range out of bounds: ${this.name}`);
this.rangeFills.push({ start, end, fills });
}
setRangeFontName(start, end, font) {
assert.ok(this.type === "TEXT" && start >= 0 && start < end && end <= this._characters.length, `Text range out of bounds: ${this.name}`);
assert.ok(loaded.has(fontKey(font)), `Load font before setting a text range: ${this.name}`);
this.rangeFonts.push({ start, end, font });
}
resize(width, height) {
assert.ok(width > 0 && height > 0 && Number.isFinite(width + height), `Node dimensions must be positive: ${this.name}`);
if (this._width !== width || this._height !== height) { this._width = width; this._height = height; dirty(this); }
}
appendChild(node) {
const previous = node.parent;
if (previous) previous.children.splice(previous.children.indexOf(node), 1);
node.parent = this;
this.children.push(node);
if (previous && previous !== this) dirty(previous);
dirty(this);
}
findAll(predicate) {
const found = [];
const walk = (node) => {
for (const child of node.children) {
if (predicate(child)) found.push(child);
walk(child);
}
};
walk(this);
return found;
}
findOne(predicate) {
for (const node of this.children) {
if (predicate(node)) return node;
const found = node.findOne(predicate);
if (found) return found;
}
return null;
}
setSharedPluginData(namespace, key, value) { this.data[`${namespace}:${key}`] = value; }
getSharedPluginData(namespace, key) { return this.data[`${namespace}:${key}`] || ""; }
createInstance() {
assert.equal(this.type, "COMPONENT");
const copy = (source) => {
const node = new Node(source.type);
for (const key of Object.keys(source)) {
if (SKIP_COPY.has(key)) continue;
node[key] = structuredClone(source[key]);
}
for (const child of source.children) node.appendChild(copy(child));
return node;
};
const instance = copy(this);
instance.type = "INSTANCE";
instance.mainComponentId = this.id;
return instance;
}
async setReactionsAsync(reactions) {
await Promise.resolve();
if (options.rejectReactions) throw new Error("reaction rejected");
for (const reaction of reactions) {
const action = reaction.actions[0];
const target = nodes.get(action.destinationId);
assert.ok(target, "Reaction destination exists");
if (action.navigation === "NAVIGATE") {
assert.equal(target.type, "FRAME");
assert.equal(target.parent.type, "PAGE", "Navigate only to a top-level frame");
let top = this;
while (top.parent && top.parent.type !== "PAGE") top = top.parent;
assert.notEqual(target, top, "Navigate must leave the source's own top-level frame");
} else {
assert.equal(action.navigation, "CHANGE_TO");
let ancestor = this;
while (ancestor && ancestor.type !== "COMPONENT") ancestor = ancestor.parent;
assert.equal(ancestor?.parent?.type, "COMPONENT_SET");
assert.equal(target.parent, ancestor.parent, "Change only to a sibling variant");
}
}
this.reactions = structuredClone(reactions);
api.connected += 1;
}
}
const api = {
nodes, connected: 0, loaded, messages: [], notices: [], layoutComputes: 0,
root: new Node("DOCUMENT"),
async listAvailableFontsAsync() {
return (options.fonts || [{ family: "Inter", style: "Regular" },
{ family: "Noto Sans SC", style: "Regular" }, { family: "Noto Sans SC", style: "Medium" },
{ family: "Fraunces", style: "SemiBold" }]).map((fontName) => ({ fontName }));
},
async loadFontAsync(font) { loaded.add(fontKey(font)); },
createPage() {
if (options.rejectPages) throw new Error("page limit reached");
const page = new Node("PAGE");
this.root.appendChild(page);
return page;
},
async setCurrentPageAsync(page) { this.currentPage = page; },
combineAsVariants(components, parent) {
const set = new Node("COMPONENT_SET");
parent.appendChild(set);
for (const component of components) {
assert.equal(component.type, "COMPONENT");
set.appendChild(component);
}
return set;
},
viewport: { scrollAndZoomIntoView(selection) { assert.ok(selection.length > 0); } },
closePlugin(message) { this.messages.push(message); },
notify(message) {
this.notices.push(message);
return { cancel() {} };
},
};
for (const type of ["Frame", "Component", "Text", "Vector", "Ellipse", "Rectangle"]) {
api[`create${type}`] = () => {
const node = new Node(type.toUpperCase());
api.currentPage.appendChild(node);
return node;
};
}
api.createNodeFromSvg = (svg) => {
const node = new Node("FRAME");
const size = Number((svg.match(/width="([\d.]+)"/) || [])[1] || 24);
node._width = node._height = Math.max(size, 1);
api.currentPage.appendChild(node);
return node;
};
const original = new Node("PAGE");
original.name = "Existing design";
api.root.appendChild(original);
api.currentPage = original;
return api;
}
module.exports = { fakeFigma };
+263
View File
@@ -0,0 +1,263 @@
# 代码阅读(Code Reader)设计
## 目标
代码几乎全由 agent 写,逐行读太慢。给插件加一个独立的「代码阅读」入口:打开一个源码文件时,可以切到「预览」,把代码拆成一棵代码块树,每块用模板直译成中文,扫一眼就知道这个文件在干什么;要看细节时单块切回源码,并且可以直接改。
它和现有的 Superpowers 看板(活动栏小火箭)是两套东西,只是放在同一个插件(`vscode/`)里。
视觉和交互以 Figma 原型为准:`docs/prototype/code-reader/figma-plugin/`(`code.js` 里的数据和 `test.js` 里的期望值就是本文的样例)。
## 已确认的决定
| 事项 | 决定 |
| --- | --- |
| 流程 | 源码 →(tree-sitter)语法树 → 通用代码块树 → 译文 |
| 第一版翻译 | 只做中文模板直译;AI 解读以后作为另一个翻译插件加 |
| 第一版语言 | TypeScript / TSX(含 JavaScript / JSX)、Python |
| 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API |
| 多种翻译怎么选 | 预览顶部下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 |
| 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 |
| 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 |
| 折叠 | 有子块的块能折叠,默认全部展开 |
| 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 |
| 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) |
## 用户可见行为
### 活动栏与侧栏
- 活动栏新增图标(书本,`book-open-text` 样式),视图容器标题「代码阅读」,里面一个视图「代码阅读」。
- 视图是工作区文件树:每个工作区文件夹一个根节点(默认展开),目录在前、文件在后,名字不区分大小写排序;遵守 `files.exclude`。
- 树节点用 `resourceUri`,图标跟随用户的文件图标主题,git 改动颜色(M / U)由 VS Code 自动加上,外观与自带资源管理器一致。
- 视图标题栏:刷新、全部折叠(`showCollapseAll`)。文件变化时自动刷新(防抖)。
- 点文件:语言受支持 → 用「代码阅读预览」自定义编辑器打开;否则 `vscode.open`。
### 编辑器右上角切换
- 受支持语言的文件:普通编辑器里标题栏显示「预览」,预览里显示「编辑」。点一下在**同一个标签页位置**换成另一种:用 VS Code 内部的 `reopenActiveEditorWith`(「重新打开编辑器的方式」背后的机制)原位替换。不能用「另开再关原标签」:文件有未保存改动时关原标签会弹保存框,选「不保存」会丢改动。
- 用 `editor/title` 的 `navigation` 组、不带图标的命令,让按钮显示文字。若实测 VS Code 不渲染文字,改成带图标(`$(open-preview)` / `$(go-to-file)`),标题保持「预览」「编辑」。VS Code 标题栏做不出原型里的分段控件,这是和原型唯一的有意差异。
- 受支持语言 = 当前注册的语言插件覆盖的 `languageId`,通过 `setContext('superpowers.codeReader.languageIds', [...])` 提供给 when 子句(`resourceLangId in superpowers.codeReader.languageIds`)。
### 预览(代码块树)
- 每一句是一块:导入、函数、类、类型、字段、常量、变量、条件、否则、循环、尝试、捕获、最终、返回、语句、注释。
- 嵌套的块放在父块下面、往右缩进;每一层左边一条竖线(颜色 `--vscode-editorIndentGuide-background`)。
- 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。
- 种类标签和底色按种类区分(颜色见下表);注释块虚线框。
- 内容两种状态,每块单独切:
- 预览:译文,按词着色,等宽字体(中文部分用系统中文字体回退)。
- 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。
- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。
- 顶部工具栏:翻译下拉框(多于一种翻译时才显示)。
- 解析失败:显示错误原因,并提示用「编辑」查看源码。
## 通用契约
放在 `vscode/src/codeReader/contract.ts`,语法树类型用 `@vscode/tree-sitter-wasm` 自带的 web-tree-sitter 类型。
```ts
export type BlockKind =
| 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable'
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
/** 源码里的一段,行号从 0 开始,end 为开区间的字节偏移。 */
export interface SourceSpan { startIndex: number, endIndex: number, startRow: number, endRow: number }
export interface CodeBlock {
kind: BlockKind
/** 这一块对应的语法树节点(整条语句)。 */
node: Node
/** 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。 */
header: SourceSpan
children: CodeBlock[]
}
/** 着色用的词性,源码着色和译文着色共用。 */
export type TokenRole =
| 'keyword' // 流程关键字:import from export if else for return try catch await …
| 'storage' // 声明关键字与字面量关键字:const function async class interface new true false null …
| 'operator' // 被翻成文字的运算符:全等 与 或 非
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
export interface Segment { text: string, role: TokenRole }
/** 语言插件:语法树 → 代码块树。 */
export interface LanguageAdapter {
id: string
/** 覆盖的 VS Code languageId,例如 ['typescript', 'typescriptreact', 'javascript', 'javascriptreact']。 */
languageIds: string[]
/** 该 languageId 用的语法包 .wasm 的绝对路径(宿主负责加载,保证和解析器运行时是同一份)。 */
grammarPath: (languageId: string) => string
toBlocks: (root: Node, source: string) => CodeBlock[]
/** 源码着色:给语法树叶子节点定词性。 */
tokenRole: (leaf: Node) => TokenRole
}
/** 翻译插件:代码块 → 译文。 */
export interface Translator {
id: string // 'typescript.zh-template'
label: string // '中文直译'
languageIds: string[]
translate: (block: CodeBlock, source: string) => Segment[]
}
export interface CodeReaderApi {
registerLanguage: (adapter: LanguageAdapter) => Disposable
registerTranslator: (translator: Translator) => Disposable
}
```
- `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。
- 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。
- 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。
## 解析运行时
- 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。
- 打包:`esbuild.mjs` 构建时把 `tree-sitter.wasm` 和用到的语法包复制到 `dist/tree-sitter/`(`package.json` 的 `files` 已包含 `dist/**/*`)。
- 运行时初始化一次 `Parser.init({ locateFile })` 指向 `dist/tree-sitter/tree-sitter.wasm`;语法包按路径懒加载并缓存。
- 文件扩展名到语法包:`.ts` → typescript;`.tsx` → tsx;`.js/.jsx/.mjs/.cjs` → javascript(tree-sitter-javascript 支持 JSX);`.py` → python。以 `languageId` 为准。
## 代码块树规则
### TypeScript / JavaScript
- 顶层与语句块内的每条语句一块;连续的行注释合成一块注释。
- `import_statement` → import。
- `export_statement` 包着声明 → 按声明定种类,节点取整个 `export_statement`(头部包含 `export`)。
- `function_declaration` / `generator_function_declaration` / `method_definition` → function,子块来自主体语句。
- 变量声明的唯一声明值是带语句块主体的箭头函数 / 函数表达式 → function(React 组件、回调常见写法),子块来自主体。
- `lexical_declaration` / `variable_declaration`:模块顶层的 `const` → const,其余 → variable。
- `class_declaration` → class,子块:方法 → function,字段 → field,注释 → comment。
- `interface_declaration` → type,子块:属性 / 方法签名 → field;`type_alias_declaration` → type;`enum_declaration` → type,成员 → field。
- `if_statement` → if,子块来自 consequence;`else` / `else if` 展平成紧跟其后的兄弟块 else(`else if` 的头部是 `else if (...)`,子块来自它的 consequence)。
- `for` / `for…in` / `for…of` / `while` / `do` → loop。
- `try_statement` → try;`catch_clause` → 兄弟块 catch;`finally_clause` → 兄弟块 finally。
- `switch_statement` → if,每个 `case` / `default` → 子块 if。
- `return_statement` → return;其余语句(含 `throw`)→ statement。
- 表达式语句的参数里带语句块主体的函数(`useEffect(() => { … })`、`describe('x', () => { … })`)→ statement,子块来自那个函数主体,头部到主体开始为止。
### Python
- `import_statement` / `import_from_statement` / `future_import_statement` → import。
- `function_definition` → function;`decorated_definition` 按内部定义定种类,头部包含装饰器。
- `class_definition` → class;类体里的带注解赋值 → field。
- 模块 / 函数 / 类主体开头的字符串表达式(docstring)和 `comment` → comment。
- 赋值:模块顶层且变量名全大写 → const,其余 → variable。
- `if_statement` → if;`elif_clause` / `else_clause` → 兄弟块 else;`for` / `while` → loop(循环的 `else` → 兄弟块 else)。
- `try_statement` → try;`except_clause` → catch;`finally_clause` → finally。
- `with_statement` → statement,子块来自主体;`match_statement` → if,`case` → 子块 if。
- `return_statement` → return;其余 → statement。
- 复合语句头部到主体开始为止(包含行尾冒号,翻译时去掉)。
## 中文模板直译
结构照抄源码,只替换三类东西,其余(标识符、属性名、字面量、标点)原样保留并按词性着色:
1. 关键字:只认语法树里的关键字节点(匿名节点),所以属性名 `i.number`、对象键不会被误翻。
2. 内置类型:只在类型位置翻(TS 的 `predefined_type`、Python 注解里的内置类型名)。
3. 运算符:`===` 全等、`!==` 不全等、`==` 等于、`!=` 不等于、`&&` 与、`||` 或、`!` 非(Python 的 `not` / `and` / `or` 本身是关键字)。
其他规则:空数组字面量 → 空数组(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释去掉注释符号,原文照搬。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。
TypeScript 关键字表(流程 = keyword,声明 = storage):
| 流程 | 声明 / 字面量 |
| --- | --- |
| import 导入、from 来自、as 作为、export 公共、default 默认、if 如果、else 否则、for 遍历、of 取自、in 在、while 当、do 执行、switch 分支、case 情况、break 跳出、continue 继续、return 返回、throw 抛出、try 尝试、catch 捕获、finally 最终、await 等待、yield 产出 | const 常量、let 变量、var 变量、function 函数、async 异步、class 类、extends 继承、implements 实现、interface 接口、type 类型、enum 枚举、new 新建、instanceof 属于、typeof 类型为、keyof 键名、readonly 只读、public 公共、private 私有、protected 受保护、static 静态、abstract 抽象、delete 删除、true 真、false 假、null 空、undefined 未定义 |
TypeScript 内置类型:string 字符串、number 数值、boolean 布尔、any 任意、unknown 未知、never 永不、void 无、object 对象、bigint 大整数、symbol 符号。`Promise` 等库类型不翻。
Python 关键字:import 导入、from 来自、as 作为、def 函数、async 异步、await 等待、class 类、return 返回、if 如果、elif 否则如果、else 否则、for 遍历、in(for 头部与推导式里)取自 / (比较里)在、while 当、try 尝试、except 捕获、finally 最终、raise 抛出、with 使用、yield 产出、lambda 匿名函数、pass 跳过、break 跳出、continue 继续、global 全局、nonlocal 非局部、del 删除、assert 断言、not 非、and 与、or 或、is 是、None 空、True 真、False 假、match 匹配、case 情况。
Python 内置类型(仅注解里):str 字符串、int 整数、float 浮点数、bool 布尔、list 列表、dict 字典、set 集合、tuple 元组、bytes 字节串。
样例(必须逐字一致,来自原型 `test.js`):
| 源码 | 译文 |
| --- | --- |
| `export async function findEnvFiles(workspaceRoot: string): Promise<string[]> {` | `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>` |
| `async function chmodAll(workspaceRoot: string, mode: number): Promise<EnvLockResult> {` | `异步 函数 chmodAll(workspaceRoot: 字符串, mode: 数值): Promise<EnvLockResult>` |
| `import { promises as fsp } from 'node:fs'` | `导入 { promises 作为 fsp } 来自 'node:fs'` |
| `if (!folder)` | `如果 (非 folder)` |
| `return []` | `返回 空数组` |
| `for (const f of files) {` | `遍历 (常量 f 取自 files)` |
| `failed: { path: string, reason: string }[]` | `failed: { path: 字符串, reason: 字符串 }[]` |
| `const issue = issues.find(i => i.number === issueNumber)` | `常量 issue = issues.find(i => i.number 全等 issueNumber)` |
| `return {⏎ locked: true,⏎ … }`(issueLock.ts 20–24 行) | `返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }` |
| `def find_env_files(root: str) -> list[str]:` | `函数 find_env_files(root: 字符串) -> 列表[字符串]` |
## 预览数据与消息
扩展端把代码块树转成可序列化的视图数据发给 webview:
```ts
interface BlockView {
id: string // 在树里的位置,例如 '6.1.0'
kind: BlockKind
startLine: number // header 的起止行,0 起
endLine: number
code: string // header 源码,去掉共同缩进
codeTokens: Segment[] // 源码着色(拼起来等于 code)
translation: Segment[]
children: BlockView[]
}
```
- 扩展 → webview:`{ type: 'document', version, languageId, blocks: BlockView[], translators: { id, label }[], translatorId, error?: string }`,文档每次变化(防抖 150ms)重发。
- webview → 扩展:
- `{ type: 'ready' }`
- `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。
- `{ type: 'selectTranslator', id }`:按 `languageId` 记到 `globalState`,重发文档。
- 块的编辑 / 折叠状态在 webview 里按块 id 记(`vscode.setState`),文档刷新后保留;正在编辑且有焦点的块,刷新时不覆盖输入框内容。
## 颜色
词性(暗色取 VS Code Dark+,亮色取 Light+):
| 词性 | 暗色 | 亮色 |
| --- | --- | --- |
| keyword | #c586c0 | #af00db |
| storage / operator | #569cd6 | #0000ff |
| variable | #9cdcfe | #001080 |
| function | #dcdcaa | #795e26 |
| type | #4ec9b0 | #267f99 |
| string | #ce9178 | #a31515 |
| number / literal | #b5cea8 | #098658 |
| comment | #6a9955 | #008000 |
| plain | `--vscode-editor-foreground` | 同左 |
种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
## 代码位置
```
vscode/src/codeReader/
contract.ts 通用契约
registry.ts 注册表 + CodeReaderApi
runtime.ts web-tree-sitter 初始化、语法包加载、解析
blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译)
languages/typescript.ts
languages/python.ts
translators/zhTemplate.ts 模板直译引擎(按语言的表驱动)
translators/typescriptZh.ts TS 表
translators/pythonZh.ts Python 表
explorer/fileTree.ts 侧栏文件树
editor/previewEditor.ts CustomTextEditorProvider
editor/messages.ts 消息类型
index.ts registerCodeReader(context): CodeReaderApi
vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口)
```
## 测试
- 语言插件:`envLock.ts`、`issueLock.ts` 的代码块树(种类、header 行号、层级)与原型 `CODE_TREE` 一致;Python 样例覆盖 import / def / docstring / if-elif-else / for / try-except / with / 推导式。
- 翻译:上面样例表逐字一致;属性名不被翻;每个被翻的词单独成段。
- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确。
- 行替换:多行块替换后缩进正确。
- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。
## 收尾
按仓库约定:`package.json` 版本 patch +1 → `pnpm ext:package` → 版本号改动一并 commit → 本机 `code --install-extension <vsix> --profile augment`。