✨ feat(prototype): 代码阅读原型加 Shell 脚本一页(解释器、选项、变量展开、分情况表、函数参数、命令规则读成中文、管道);插件菜单分主画板和 Shell 脚本

This commit is contained in:
cruldra committed 2026-09-27 23:59:06 +08:00
1 parent 3acf3e0007
commit 65583a001c
4 files changed
+297 -20

No files matched your search

@@ -41,11 +41,17 @@
| `Prototype / 文件注释 · 填写` | 同上 | 点「添加注释」后 VS Code 顶部弹出单行输入框,标题带上目录 / 文件名;回车保存,Esc 取消,清空后保存即删除 |
| `Prototype / 文件注释 · 已保存` | 同上 | 有注释的目录 / 文件在名字后面灰字显示注释,太长截断;鼠标停在有注释的行上,按钮换成「编辑注释」,悬停提示里是路径和注释全文 |
## Shell 脚本(插件菜单「Shell 脚本」,单独一页 `代码阅读器 · Shell 脚本`)
| 画板 | 对应源码 | 设计点 |
| --- | --- | --- |
| `Prototype / Shell · fetch-runtime.sh` | luffy 桌面端 `apps/desktop/scripts/fetch-runtime.sh` | bash 语言插件拆块,命令的意思由「命令规则」按命令名注册(rm、mkdir、curl、tar、find……),像库规则一样把选项读成中文。块:解释器(#! 用哪个解释器、怎么找);选项(set -euo pipefail 每个开关一行);变量(`${1:-}`、`$(…)`、`${x:-默认}` 读成「第 1 个参数(没给就是空)」「执行 … 的输出」「x(没设就用 …)」,大写的当常量上色);分情况(每个情况都只有几句时画成「情况 / 做什么」表,有长的就每个情况一块子块,`*.zip` 读成「以 .zip 结尾」);函数(紧贴的注释当说明;按 `local x="$1"` 认出第几个参数叫什么;输出写它打印的东西);命令(「删除 dest(连同里面的东西,不提示)」「下载 url 到 out.part(失败就报错、跟着跳转走、最多重试 3 次)」,`>&2` 写「打印到错误输出」);管道(a → b,后面的 while read 照常是子块);如果 / 遍历(`[ -f x ]`、`[[ x == *-y ]]`、`x && y \|\| continue` 读成中文) |
## 导入与运行
1. 打开 Figma Desktop,打开任意 Design 文件。
2. `Plugins > Development > Import plugin from manifest…`,选本目录的 `manifest.json`(已经导入过就跳过)。
3. `Plugins > Development > 代码阅读器 Design Lab` 运行。改过 `code.js` 后要重启 Figma 才会用新代码。
3. `Plugins > Development > 代码阅读器 Design Lab`,选「主画板」或「Shell 脚本」,各自新建一页。改过 `code.js` 后要重启 Figma 才会用新代码。
## 运行条件
+244 -16
View File
@@ -675,10 +675,10 @@ function highlightCode(code) {
// ─── Small helpers ───────────────────────────────────────────────────────────
function nextPageName(pages) {
function nextPageName(pages, base = PAGE_NAME) {
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}`;
let name = base;
for (let index = 2; names.has(name); index += 1) name = `${base} ${index}`;
return name;
}
@@ -913,6 +913,10 @@ const LUCIDE = {
"calendar-clock": [["path", { d: "M16 14v2.2l1.6 1" }], ["path", { d: "M16 2v4" }], ["path", { d: "M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" }], ["path", { d: "M3 10h5" }], ["path", { d: "M8 2v4" }], ["circle", { cx: "16", cy: "16", r: "6" }]],
"globe": [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }], ["path", { d: "M2 12h20" }]],
"network": [["rect", { x: "16", y: "16", width: "6", height: "6", rx: "1" }], ["rect", { x: "2", y: "16", width: "6", height: "6", rx: "1" }], ["rect", { x: "9", y: "2", width: "6", height: "6", rx: "1" }], ["path", { d: "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }], ["path", { d: "M12 12V8" }]],
"square-terminal": [["path", { d: "m7 11 2-2-2-2" }], ["path", { d: "M11 13h4" }], ["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }]],
"signpost": [["path", { d: "M12 13v8" }], ["path", { d: "M12 3v3" }], ["path", { d: "M2.354 10.354a1.207 1.207 0 0 1 0-1.708l2.06-2.06A2 2 0 0 1 5.828 6h12.344a2 2 0 0 1 1.414.586l2.06 2.06a1.207 1.207 0 0 1 0 1.708l-2.06 2.06a2 2 0 0 1-1.414.586H5.828a2 2 0 0 1-1.414-.586z" }]],
"sliders-horizontal": [["path", { d: "M10 5H3" }], ["path", { d: "M12 19H3" }], ["path", { d: "M14 3v4" }], ["path", { d: "M16 17v4" }], ["path", { d: "M21 12h-9" }], ["path", { d: "M21 19h-5" }], ["path", { d: "M21 5h-7" }], ["path", { d: "M8 10v4" }], ["path", { d: "M8 12H3" }]],
"terminal": [["path", { d: "M12 19h8" }], ["path", { d: "m4 17 6-6-6-6" }]],
"package": [["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }], ["path", { d: "M12 22V12" }], ["polyline", { points: "3.29 7 12 12 20.71 7" }], ["path", { d: "m7.5 4.27 9 5.15" }]],
"git-pull-request": [["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["path", { d: "M6 9v12" }]],
"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" }]],
@@ -1648,7 +1652,8 @@ function blockToolsComponent(ctx, state) {
}
/** 一层代码块:左边一条竖线标出这一层,右边是这一层的块,块里嵌套的下一层紧跟在块下面。 */
function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Level ${depth}`) {
/** 一层块的外框:左边一条竖线标出这一层,返回放块的那一列。 */
function levelList(ctx, parent, name) {
const level = filled(ctx, parent, name, { dir: "HORIZONTAL", gap: 8 });
const guide = append(level, frame(ctx, "Guide", { width: 12, align: "CENTER" }));
guide.layoutSizingVertical = "FILL";
@@ -1658,7 +1663,11 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Lev
line.fills = paint(COLORS.guide);
guide.appendChild(line);
line.layoutSizingVertical = "FILL";
const list = append(level, frame(ctx, "Blocks", { width: 10, gap: 8 }), true);
return { level, list: append(level, frame(ctx, "Blocks", { width: 10, gap: 8 }), true) };
}
function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Level ${depth}`) {
const { level, list } = levelList(ctx, parent, name);
for (const item of nodes) {
const state = item.edit ? "edit" : "preview";
const line = lineRange(item.at)[0];
@@ -2122,14 +2131,19 @@ function breadcrumb(ctx, fileKey) {
return node;
}
function createComponents(ctx, page) {
const sets = [];
const lib = {};
const make = (name, entries, columns, build, node = (item) => item) => timed(name, () => {
/** 建一个组件集:entries 每项 build 出一个变体,返回 entry → build 的结果。 */
function setMaker(ctx, page, sets) {
return (name, entries, columns, build, node = (item) => item) => timed(name, () => {
const map = new Map(entries.map((entry) => [entry, build(entry)]));
sets.push(componentSet(ctx, page, name, [...map.values()].map(node), columns));
return map;
});
}
function createComponents(ctx, page) {
const sets = [];
const lib = {};
const make = setMaker(ctx, page, sets);
const fileKeys = Object.keys(FILES);
ctx.lib = lib;
lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode));
@@ -2477,19 +2491,233 @@ function kindLegend(ctx, page, lib) {
return board;
}
// ─── Shell 脚本 ──────────────────────────────────────────────────────────────
//
// bash / sh / zsh 用 tree-sitter 的 bash 语法拆块,和别的语言一样走通用接口;命令的意思由「命令规则」按命令名注册
// (rm、mkdir、curl、tar、unzip、find、cp、mv、echo、exit……),像库规则一样:规则把选项读成中文(-rf → 连同里面的东西、不提示)。
// 各种块:
// - 解释器:第一行 #!,写用哪个解释器、怎么找到它;
// - 选项:set -euo pipefail 这类,每个开关一行写它的意思;
// - 变量:值里的 ${1:-}、$(…)、${x:-默认} 读成中文(第 1 个参数、执行 … 的输出、没设就用 …);大写的当常量上色;
// - 分情况:case。每个情况都只有几句、没有嵌套时,画成一张表(情况 / 做什么);有长的情况就每个情况一块子块;
// 情况里的通配读成中文(*.zip → 以 .zip 结尾,* → 其他);
// - 函数:紧贴在函数上面的注释当函数的说明,不再单独成块;shell 函数不写参数,按函数里的 local x="$1" 认出第几个参数叫什么,
// 没起名的写 $1;输出写它打印的东西,返回写最后一句成不成功;
// - 命令:一行写一句,命令规则读出的中文;重定向写成「打印到错误输出」这类;
// - 管道:a | b | c 写成「a → b → c」,while read 这种跟在管道后面的循环照常是子块;
// - 如果 / 遍历:[ -f x ]、[[ x == *-y ]]、x && y || continue 读成中文条件。
// 原型的译文按这份设计手写;真实插件里由 bash 语言插件、中文翻译插件和命令规则生成。
// 示例:luffy 桌面端 apps/desktop/scripts/fetch-runtime.sh。
const SHELL_PAGE_NAME = "代码阅读器 · Shell 脚本";
const SHELL_FILE = "apps/desktop/scripts/fetch-runtime.sh";
const SHELL_KINDS = {
comment: BLOCK_KINDS.comment,
variable: BLOCK_KINDS.variable,
const: BLOCK_KINDS.const,
function: BLOCK_KINDS.function,
if: BLOCK_KINDS.if,
loop: BLOCK_KINDS.loop,
interpreter: { label: "解释器", color: "#9d9d9d", icon: "terminal" },
options: { label: "选项", color: "#e5c07b", icon: "sliders-horizontal" },
case: { label: "分情况", color: "#ce9178", icon: "signpost" },
command: { label: "命令", color: "#d7ba7d", icon: "square-terminal" },
pipeline: { label: "管道", color: "#d7ba7d", icon: "square-terminal" },
};
// 片段写法:[k|如果] 流程词、[s|是] 声明词、[v|变量]、[c|命令]、[f|函数]、[t|类型]、[q|字符串]、[n|数字]、[d|淡色说明],其余是正文
const SHELL_TOKEN_COLORS = { k: "tokenControl", s: "tokenStorage", v: "tokenVariable", c: "tokenFunction", f: "tokenFunction",
t: "tokenType", q: "tokenString", n: "tokenLiteral", d: "desc", u: "tokenConstant" };
function rich(markup) {
const segments = [];
let last = 0;
const token = /\[([ksvcftqndu])\|([^\]]*)\]/g;
let match;
while ((match = token.exec(markup))) {
if (match.index > last) segments.push({ text: markup.slice(last, match.index) });
segments.push({ text: match[2], color: COLORS[SHELL_TOKEN_COLORS[match[1]]] });
last = match.index + match[0].length;
}
if (last < markup.length) segments.push({ text: markup.slice(last) });
return segments;
}
const HEADER_COMMENT = [
"下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime/<target>/{git,python}。",
"",
" scripts/fetch-runtime.sh <target>",
"",
"支持的 target:",
" x86_64-pc-windows-msvc MinGit 64-bit (git-for-windows) + python-build-standalone",
" aarch64-apple-darwin dugite-native macOS arm64 (GitHub Desktop 的可搬移 git) + python-build-standalone",
"",
"构建 (含 cargo test / tauri dev) 前先跑一次: tauri 按目标平台的配置把这个目录打进安装包",
"(见 src-tauri/tauri.<os>.conf.json), 目录缺了构建直接失败。",
"下载的包按版本缓存在 ~/.cache/luffy-desktop-runtime, 校验 sha256; 国内访问 GitHub 需要",
"先 export https_proxy。版本升级时同时改下面的地址与 sha256。",
].join("\n");
const STDERR = "[d|(打印到错误输出)]";
// line:源码行号;kind:种类;text:一行译文;rows:属性行 [名, 值];table:{ columns, rows };kids:子块
const SHELL_BLOCKS = [
{ line: 1, kind: "interpreter", rows: [["解释器", "[c|bash]"], ["怎么找", "[d|按 PATH 去找(/usr/bin/env bash)]"]] },
{ line: 2, kind: "comment", text: `[d|${HEADER_COMMENT}]` },
{ line: 14, kind: "options", rows: [["出错就停", "[s|是] [d|-e]"], ["用到没设的变量就报错", "[s|是] [d|-u]"], ["管道里任一步失败都算失败", "[s|是] [d|-o pipefail]"]] },
{ line: 16, kind: "variable", rows: [["名称", "[v|target]"], ["初始值", "[v|第 1 个参数][d|(没给就是空)]"]] },
{ line: 17, kind: "variable", rows: [["名称", "[v|here]"], ["初始值", "[d|执行] 〔[c|进入] [v|脚本所在目录][q|/..] [k|然后] [c|打印当前目录]〕 [d|的输出]"]] },
{ line: 18, kind: "variable", rows: [["名称", "[v|dest]"], ["初始值", "[v|here][q|/src-tauri/resources/runtime/][v|target]"]] },
{ line: 19, kind: "variable", rows: [["名称", "[v|cache]"], ["初始值", "[v|XDG_CACHE_HOME][d|(没设就用 ][v|HOME][q|/.cache][d|)][q|/luffy-desktop-runtime]"]] },
{ line: 21, kind: "const", rows: [["名称", "[u|PY_RELEASE]"], ["值", "[n|20260924]"]] },
{ line: 22, kind: "const", rows: [["名称", "[u|PY_VERSION]"], ["值", "[n|3.13.15]"]] },
{ line: 24, kind: "case", text: "[k|按] [v|target] [k|分情况]", table: { columns: [["情况", 200], ["做什么", 0]], rows: [
["[q|x86_64-pc-windows-msvc]", "[u|GIT_URL] = [q|https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/MinGit-2.55.0.5-64-bit.zip]\n[u|GIT_SHA256] = [q|56d7b226b7693196cfc71fef26568f536c4a021ab6c37ff2db4287bed908e96e]\n[u|PY_SHA256] = [q|e42fa944748a50e9ff481cbb817ef8a6e3da6fbcf0cf6f29b554e1acb8c7384d]"],
["[q|aarch64-apple-darwin]", "[u|GIT_URL] = [q|https://github.com/desktop/dugite-native/releases/download/v2.53.0-4/dugite-native-v2.53.0-4098283-macOS-arm64.tar.gz]\n[u|GIT_SHA256] = [q|f9dc64635a5b62fbd7ad95db73268bbb8912255ac516d65d37bf7af22fcb8ffe]\n[u|PY_SHA256] = [q|064afb7c2fc0bbf511d886288adf98696af5105e36c138cdf2c199c0146fcf68]"],
["[d|其他]", `[c|打印] [q|"用法: ][v|脚本路径][q| <x86_64-pc-windows-msvc|aarch64-apple-darwin>"]${STDERR}\n[c|退出][d|,退出码 ][n|2]`],
] } },
{ line: 40, kind: "variable", rows: [["名称", "[u|PY_URL]"], ["初始值", "[q|https://github.com/astral-sh/python-build-standalone/releases/download/][u|PY_RELEASE][q|/cpython-][u|PY_VERSION][q|%2B][u|PY_RELEASE][q|-][v|target][q|-install_only_stripped.tar.gz]"]] },
{ line: 42, kind: "function", rows: [["名称", "[f|sha_ok]"], ["输入参数", "[d|2 个,按位置]"], ["返回", "[d|最后一句成不成功]"]],
table: { columns: [["位置", 120], ["函数里叫", 0]], rows: [["[d|第 1 个]", "[v|$1]"], ["[d|第 2 个]", "[v|$2]"]] },
kids: [{ line: 43, kind: "if", text: "[k|成立:]文件 [v|$1] [k|存在] [k|并且] 〔[c|算] [v|$1] [c|的 sha256] [k|→] [c|按空格切,取第 1 段]〕 [k|等于] [v|$2]" }] },
{ line: 47, kind: "function", rows: [["名称", "[f|fetch]"], ["说明", "[d|下载 (已缓存且校验通过就不重下), 回显本地文件路径]"], ["输入参数", "[d|2 个,按位置]"], ["输出", "[c|打印] [v|out][d|(调用处用 $(fetch …) 拿到)]"]],
table: { columns: [["位置", 120], ["函数里叫", 0]], rows: [["[d|第 1 个]", "[v|url]"], ["[d|第 2 个]", "[v|sha]"]] },
kids: [
{ line: 48, kind: "variable", text: "[s|局部] [v|url] = [v|第 1 个参数],[v|sha] = [v|第 2 个参数],[v|out]" },
{ line: 49, kind: "variable", text: "[v|out] = [v|cache][q|/]〔[c|取文件名] [v|url][d|(其中 %2B 全换成 +)]〕" },
{ line: 50, kind: "command", text: "[c|建目录] [v|cache][d|(连同上级目录)]" },
{ line: 51, kind: "if", text: "[k|如果] [k|不] [f|sha_ok] [v|out] [v|sha]", kids: [
{ line: 52, kind: "command", text: `[c|打印] [q|"下载 ][v|url][q|"]${STDERR}` },
{ line: 53, kind: "command", text: "[c|下载] [v|url] [k|到] [v|out][q|.part][d|(失败就报错、跟着跳转走、最多重试 3 次)]" },
{ line: 54, kind: "command", text: "[c|改名] [v|out][q|.part] [k|→] [v|out]" },
] },
{ line: 56, kind: "if", text: "[k|如果] [k|不] [f|sha_ok] [v|out] [v|sha]", kids: [
{ line: 57, kind: "command", text: `[c|打印] [q|"sha256 不符: ][v|out][q|"]${STDERR}` },
{ line: 58, kind: "command", text: "[c|退出][d|,退出码 ][n|1]" },
] },
{ line: 60, kind: "command", text: "[c|打印] [v|out]" },
] },
{ line: 63, kind: "variable", rows: [["名称", "[v|git_pkg]"], ["初始值", "[d|执行] 〔[f|fetch] [u|GIT_URL] [u|GIT_SHA256]〕 [d|的输出]"]] },
{ line: 64, kind: "variable", rows: [["名称", "[v|py_pkg]"], ["初始值", "[d|执行] 〔[f|fetch] [u|PY_URL] [u|PY_SHA256]〕 [d|的输出]"]] },
{ line: 66, kind: "command", text: "[c|删除] [v|dest][d|(连同里面的东西,不提示)]" },
{ line: 67, kind: "command", text: "[c|建目录] [v|dest][q|/git][d|(连同上级目录)]" },
{ line: 68, kind: "case", text: "[k|按] [v|git_pkg] [k|分情况]", table: { columns: [["情况", 200], ["做什么", 0]], rows: [
["[k|以] [q|.zip] [k|结尾]", "[c|解压 zip] [v|git_pkg] [k|到] [v|dest][q|/git][d|(不打印过程)]"],
["[d|其他]", "[c|解压 tar.gz] [v|git_pkg] [k|到] [v|dest][q|/git]"],
] } },
{ line: 72, kind: "comment", text: "[d|python-build-standalone 的包里顶层目录就叫 python]" },
{ line: 73, kind: "command", text: "[c|解压 tar.gz] [v|py_pkg] [k|到] [v|dest]" },
{ line: 75, kind: "comment", text: "[d|── 去掉 Git Credential Manager ──\n两个 git 包都带了 GCM (.NET 写的图形界面凭证助手, 占包体大头: macOS 约 120MB、Windows 约 60MB)。\n平台的 git 在后台跑, 凭证由平台写进仓库配置, 不能弹窗, 用不上它。]" },
// 情况里句子多、带注释:不画成表,每个情况一块子块
{ line: 78, kind: "case", text: "[k|按] [v|target] [k|分情况]", kids: [
{ line: 79, kind: "case", text: "[k|情况:以] [q|-apple-darwin] [k|结尾]", kids: [
{ line: 80, kind: "variable", text: "[v|gc] = [v|dest][q|/git/libexec/git-core]" },
{ line: 81, kind: "command", text: "[c|删除] [v|gc][q|/git-credential-manager*]、[v|gc][q|/*.dll]、[v|gc][q|/*.dylib]、[v|gc][q|/createdump]、[v|gc][q|/NOTICE]、[v|gc][q|/uninstall.sh][d|(连同里面的东西,不提示)]" },
{ line: 83, kind: "comment", text: "[d|.NET 的各语言资源目录; git 自己的子目录只有 mergetools]" },
{ line: 84, kind: "command", text: "[c|找出] [v|gc] [d|下一层的目录][k|,除了] [q|mergetools][k|,逐个] [c|删除][d|(连同里面的东西,不提示)]" },
] },
{ line: 86, kind: "case", text: "[k|情况:以] [q|-windows-msvc] [k|结尾]", kids: [
{ line: 87, kind: "comment", text: "[d|mingw64/bin 里 git 自己依赖的 DLL 都以 lib 开头 (外加 zlib1.dll), 其余 DLL 都属于 GCM;\nlib 开头的只有 HarfBuzzSharp / SkiaSharp 两个属于 GCM]" },
{ line: 89, kind: "variable", text: "[v|mb] = [v|dest][q|/git/mingw64/bin]" },
{ line: 90, kind: "command", text: "[c|删除] [v|mb][q|/git-credential-manager.exe*]、[v|mb][q|/libHarfBuzzSharp.dll]、[v|mb][q|/libSkiaSharp.dll][d|(不提示)]" },
{ line: 91, kind: "command", text: "[c|找出] [v|mb] [d|这一层的] [q|*.dll][k|,除了] [q|lib*] [k|和] [q|zlib1.dll][k|,逐个] [c|删除]" },
] },
] },
{ line: 105, kind: "if", text: "[k|如果] [v|target] [k|以] [q|-apple-darwin] [k|结尾]", kids: [
{ line: 106, kind: "variable", text: "[v|gc] = [v|dest][q|/git/libexec/git-core]" },
{ line: 109, kind: "loop", text: "[k|遍历] [v|gc][q|/git-*] [d|匹配到的每个文件] [v|f]", kids: [
{ line: 110, kind: "if", text: "[k|如果不是] 〔[v|f] [k|是符号链接] [k|并且] [v|f] [k|指向] [q|git]〕[k|,跳过这一个]" },
{ line: 111, kind: "case", text: "[k|按] 〔[c|取文件名] [v|f]〕 [k|分情况]", table: { columns: [["情况", 320], ["做什么", 0]], rows: [
["[q|git-upload-pack] [k|或] [q|git-receive-pack] [k|或] [q|git-upload-archive]", "[d|什么也不做]"],
["[d|其他]", "[c|删除] [v|f]"],
] } },
] },
{ line: 123, kind: "pipeline", text: "[c|找出] [v|dest] [d|下面所有符号链接] [k|→] [k|逐行读进] [v|link]", kids: [
{ line: 124, kind: "command", text: "[c|复制] [v|link] [k|指向的实体文件] [k|到] [v|link][q|.tmp]" },
{ line: 125, kind: "command", text: "[c|改名] [v|link][q|.tmp] [k|→] [v|link]" },
] },
] },
{ line: 129, kind: "command", text: `[c|打印] [q|"自带运行时就绪: ][v|dest][q|"]${STDERR}` },
{ line: 130, kind: "command", text: `[c|统计占用空间] [v|dest][q|/git]、[v|dest][q|/python][d|(按人读的单位,只看总数)]${STDERR}` },
];
/** Shell / 块 — Line=<行号>:一块,按 spec 画一行译文、属性行、表格。 */
function shellBlock(ctx, spec) {
const kind = SHELL_KINDS[spec.kind];
const { node, content } = cardShell(ctx, `Line=${spec.line}`, kind, { kids: Boolean(spec.kids) });
if (spec.text) richText(ctx, content, "Text", rich(spec.text));
const rows = spec.rows || [];
rows.forEach(([label, value], index) => {
const twisty = spec.table && index === rows.length - (spec.kind === "function" ? 2 : 1);
propRow(ctx, content, label, rich(value), { twisty });
});
if (spec.table) {
// 分情况的表跟在头一行下面;函数的参数表跟在「输入参数」下面,「输出 / 返回」挪到表后面
propTable(ctx, content, "Table", spec.table.columns, spec.table.rows.map(([a, b], index) => [`Row / ${index + 1}`, [rich(a), rich(b)]]));
if (spec.kind === "function") content.appendChild(named(content, `Prop / ${rows[rows.length - 1][0]}`));
}
return { node };
}
function shellSpecs(specs, visit) {
for (const spec of specs) {
visit(spec);
if (spec.kids) shellSpecs(spec.kids, visit);
}
}
/** 一层 shell 块,和代码块树同样的竖线和缩进。 */
function shellLevel(ctx, parent, lib, specs, name) {
const { level, list } = levelList(ctx, parent, name);
for (const spec of specs) {
const instance = lib.shell.get(spec.line).node.createInstance();
instance.name = `Block / L${spec.line}`;
append(list, instance, true);
if (spec.kids) shellLevel(ctx, list, lib, spec.kids, `Children / L${spec.line}`);
}
return level;
}
/** 「Shell 脚本」这一页:只建它用得到的组件和一张画板。 */
function buildShell(ctx, api, page, stage) {
stage("生成组件");
const sets = [];
const lib = {};
ctx.lib = lib;
const make = setMaker(ctx, page, sets);
lib.tools = make("Reader / BlockTools", MODES.slice().reverse(), 2, (state) => blockToolsComponent(ctx, state));
const lines = [];
shellSpecs(SHELL_BLOCKS, (spec) => lines.push(spec.line));
const byLine = new Map();
shellSpecs(SHELL_BLOCKS, (spec) => byLine.set(spec.line, spec));
lib.shell = make(`Shell / ${SHELL_FILE.split("/").pop()}`, lines, 2, (line) => shellBlock(ctx, byLine.get(line)), (item) => item.node);
stage("生成演示画板");
const board = frame(ctx, `Prototype / Shell · ${SHELL_FILE.split("/").pop()}`, { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, fill: COLORS.editor });
page.appendChild(board);
timed(board.name, () => shellLevel(ctx, board, lib, SHELL_BLOCKS, "Level 0"));
stage("排布画板");
board.x = 0;
board.y = 0;
let setY = 0;
for (const set of sets) {
set.x = board.width + 160;
set.y = setY;
setY += set.height + 64;
}
for (const node of page.children) node.expanded = false;
api.viewport.scrollAndZoomIntoView([board]);
return { clock, page, components: { lib, sets }, board, screens: [board], start: board.name };
}
// ─── Entry ───────────────────────────────────────────────────────────────────
async function build(api, stage = () => {}) {
/** command:插件菜单里点的哪一项,main = 主画板,shell = Shell 脚本那一页。 */
async function build(api, stage = () => {}, command = "main") {
clock = new Map();
stage("加载中文字体");
const ctx = { api, fonts: await chooseFonts(api) };
stage("新建独立页面");
const name = nextPageName(api.root.children);
const name = nextPageName(api.root.children, command === "shell" ? SHELL_PAGE_NAME : PAGE_NAME);
const page = api.createPage();
page.name = name;
await api.setCurrentPageAsync(page);
page.backgrounds = paint("#111214");
page.setSharedPluginData(NAMESPACE, "generator", GENERATOR);
if (command === "shell") return buildShell(ctx, api, page, stage);
stage("生成组件");
const components = createComponents(ctx, page);
@@ -2649,7 +2877,7 @@ async function build(api, stage = () => {}) {
// 不选中任何画板(选中会把它在图层面板里层层展开),顶层全部收起
for (const node of page.children) node.expanded = false;
api.viewport.scrollAndZoomIntoView([start]);
return { clock, page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, routeBoard, callBoard, kindBoard, askBoard, screens };
return { start: start.name, clock, page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, routeBoard, callBoard, kindBoard, askBoard, screens };
}
/** 各阶段用时,写进结束提示,慢了看得出慢在哪一步。 */
@@ -2669,12 +2897,12 @@ async function run(api) {
const notice = api.notify("正在生成原型,请稍候…", { timeout: Infinity });
await new Promise((resolve) => setTimeout(resolve, 50));
try {
const { clock: spent } = await build(api, (stage) => {
const { clock: spent, start } = await build(api, (stage) => {
currentStage = stage;
marks.push([stage, Date.now()]);
});
}, api.command || "main");
notice.cancel();
api.closePlugin(`已生成「代码阅读」的组件与演示画板,${timingText(marks, Date.now(), spent)}。从「Prototype / envLock.ts · 预览」开始演示。`);
api.closePlugin(`已生成「代码阅读」的组件与演示画板,${timingText(marks, Date.now(), spent)}。从「${start}」开始看。`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
notice.cancel();
@@ -2683,6 +2911,6 @@ async function run(api) {
}
if (typeof module !== "undefined") {
module.exports = { NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments };
module.exports = { SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments };
}
if (typeof figma !== "undefined") void run(figma);
@@ -2,9 +2,23 @@
"name": "代码阅读器 Design Lab",
"api": "1.0.0",
"main": "code.js",
"editorType": ["figma"],
"editorType": [
"figma"
],
"documentAccess": "dynamic-page",
"networkAccess": {
"allowedDomains": ["none"]
"allowedDomains": [
"none"
]
},
"menu": [
{
"name": "主画板",
"command": "main"
},
{
"name": "Shell 脚本",
"command": "shell"
}
]
}
+30 -1
View File
@@ -2,7 +2,7 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js");
const { SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js");
const { fakeFigma } = require("../../figma-lab/fake-figma");
function action(node) { return node.reactions[0].actions[0]; }
@@ -100,6 +100,8 @@ test("manifest stays local, without a fabricated plugin ID", () => {
assert.deepEqual(manifest.editorType, ["figma"]);
assert.deepEqual(manifest.networkAccess.allowedDomains, ["none"]);
assert.equal("id" in manifest, false);
// 插件菜单分两项:主画板、Shell 脚本,各自只生成自己那一页
assert.deepEqual(manifest.menu.map((item) => item.command), ["main", "shell"]);
});
test("plugin source passes Figma's load-time scan", () => {
@@ -519,6 +521,33 @@ test("calls written over several lines spread their arguments into a table; one-
assert.equal(row.findAll((node) => node.name.startsWith("Arg / ")).length, 4);
});
test("the Shell menu item builds its own page: blocks, case tables, function parameters", async () => {
const api = fakeFigma();
const result = await build(api, () => {}, "shell");
assert.equal(result.page.name, SHELL_PAGE_NAME);
const lines = [];
const visit = (specs) => specs.forEach((spec) => { lines.push(spec.line); if (spec.kids) visit(spec.kids); });
visit(SHELL_BLOCKS);
assert.equal(new Set(lines).size, lines.length, "one block per source line");
const board = result.board;
assert.deepEqual(board.findAll((node) => node.type === "INSTANCE" && node.name.startsWith("Block / L")).map((node) => Number(node.name.slice(9))), lines);
const block = (line) => result.components.lib.shell.get(line).node;
// 情况都短的 case 画成表,情况长的每个情况一块子块
assert.equal(named(named(block(24), "Table"), "Table").children.length - 1, 3);
assert.ok(!named(block(78), "Table"));
assert.ok(SHELL_BLOCKS.find((spec) => spec.line === 78).kids.every((spec) => spec.kind === "case"));
// 函数:参数表跟在「输入参数」后面,「输出」在表后面;紧贴的注释当说明,不单独成块
const fetch = named(block(47), "Content");
assert.deepEqual(fetch.children.map((node) => node.name), ["Prop / 名称", "Prop / 说明", "Prop / 输入参数", "Table", "Prop / 输出"]);
assert.ok(!lines.includes(46));
assert.ok(named(block(1), "Kind / 解释器"));
assert.ok(named(block(14), "Kind / 选项"));
let owned = 0;
const walk = (node) => { owned += 1; if (node.type !== "INSTANCE") node.children.forEach(walk); };
result.page.children.forEach(walk);
assert.ok(owned < 3000, `shell page owns ${owned} layers`);
});
test("block kinds show as icons, the legend lists every kind with its name", async () => {
const api = fakeFigma();
const { kindBoard } = await build(api);