From 65583a001cc5d71b6dde199430963720de7182d5 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 23:59:06 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(prototype):=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=98=85=E8=AF=BB=E5=8E=9F=E5=9E=8B=E5=8A=A0=20Shell?= =?UTF-8?q?=20=E8=84=9A=E6=9C=AC=E4=B8=80=E9=A1=B5=EF=BC=88=E8=A7=A3?= =?UTF-8?q?=E9=87=8A=E5=99=A8=E3=80=81=E9=80=89=E9=A1=B9=E3=80=81=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=B1=95=E5=BC=80=E3=80=81=E5=88=86=E6=83=85=E5=86=B5?= =?UTF-8?q?=E8=A1=A8=E3=80=81=E5=87=BD=E6=95=B0=E5=8F=82=E6=95=B0=E3=80=81?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E8=A7=84=E5=88=99=E8=AF=BB=E6=88=90=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E3=80=81=E7=AE=A1=E9=81=93=EF=BC=89=EF=BC=9B=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E8=8F=9C=E5=8D=95=E5=88=86=E4=B8=BB=E7=94=BB=E6=9D=BF?= =?UTF-8?q?=E5=92=8C=20Shell=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../code-reader/figma-plugin/README.md | 8 +- .../code-reader/figma-plugin/code.js | 260 ++++++++++++++++-- .../code-reader/figma-plugin/manifest.json | 20 +- .../code-reader/figma-plugin/test.js | 31 ++- 4 files changed, 298 insertions(+), 21 deletions(-) diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index 77b9d9c..c0eddfb 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -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 才会用新代码。 ## 运行条件 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index c2e10ee..7e86995 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -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//{git,python}。", + "", + " scripts/fetch-runtime.sh ", + "", + "支持的 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..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| "]${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); diff --git a/docs/prototype/code-reader/figma-plugin/manifest.json b/docs/prototype/code-reader/figma-plugin/manifest.json index ad1d4da..27e55ee 100644 --- a/docs/prototype/code-reader/figma-plugin/manifest.json +++ b/docs/prototype/code-reader/figma-plugin/manifest.json @@ -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" + } + ] } diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js index 7bcfc5a..c35f6ca 100644 --- a/docs/prototype/code-reader/figma-plugin/test.js +++ b/docs/prototype/code-reader/figma-plugin/test.js @@ -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);