Rust in Everything
基于 topcoat-cli 0.10.0

tailwind、font、icon 与 topcoat CLI

主线位置:发现主线的外围。上一页的 asset! 和 bundler 是底座;本页讲建在它上面的三个集成,和把「构建、打包、重启」串起来的 topcoat 命令行。tailwind、iconify、Fontsource 都要联网下载,本卷的实验环境离线,这三处只读源码,未实测。

flowchart TB
  subgraph build["构建期(用户的 build.rs)"]
    tw["tailwind::BuildConfig::render:下载 Tailwind CLI,写 OUT_DIR/tailwind.css"]
    ic["iconify::BuildConfig::stage:下载 icons.json,放进 OUT_DIR"]
  end
  subgraph expand["宏展开"]
    ss["stylesheet!():asset!(OUT_DIR/tailwind.css)"]
    inc["iconify::include!:读 OUT_DIR 里的 JSON,生成 IconData 常量"]
    fs["fontsource_font!:查编译进来的字体目录"]
  end
  subgraph bundle["打包(topcoat asset bundle / topcoat dev)"]
    b["bundler:复制 tailwind.css,下载 jsDelivr 上的 woff2"]
  end
  tw --> ss --> b
  ic --> inc
  fs -->|"Host::Asset"| b
  fs -->|"Host::JsDelivr"| cdn["页面直接引用 CDN"]

tailwind:build.rs 里的独立 CLI

topcoat-tailwind 只有 0.7k 行。用法是在自己的 build.rs 里:

fn main() {
    topcoat::tailwind::BuildConfig::new().render().unwrap();
}

render 找到一个 Tailwind 可执行文件,以包根目录为工作目录、@import "tailwindcss"; 为默认输入,把压缩后的 CSS 写到 $OUT_DIR/tailwind.css。页面上的 stylesheet!() 就是:

macro_rules! stylesheet {
    () => {
        ::topcoat::asset::asset!(concat!(env!("OUT_DIR"), "/tailwind.css"))
    };
}

一个指向 OUT_DIR 绝对路径的普通 asset!。生成的 CSS 不进二进制,进资产包,按内容哈希命名、immutable 缓存。

可执行文件从哪来,download_from_github 写得很清楚:

  • 默认版本 DEFAULT_VERSION = "4.3.2",地址 https://github.com/tailwindlabs/tailwindcss/releases/download/v<版本>/<平台对应的文件名>。
  • 缓存在 <target>/topcoat/cache/tailwind/tailwindcss-<版本>-<平台>,平台写进文件名,挂载共享的 target 目录不会拿错别的平台的二进制;拿不到 target 目录就退回 OUT_DIR。
  • 下载期间用文件锁(File::lock)串行化,多个 crate 同时构建只下载一次;锁文件故意不删,注释解释了删掉会让等待者和新进程锁住不同的 inode。
  • version_checksum("4.3.2", "sha256:...") 时下载完校验,不传就不校验;缓存命中时也不再校验。
  • executable("tailwindcss") 或 executable_env("TAILWIND_CLI") 用已安装的 CLI,不下载。

所以「不需要 Node」是真的,但第一次构建要能访问 GitHub。CI、离线环境、或者只能通过镜像出网的服务器,要么预先放好缓存,要么用 executable_env 指向自备的二进制。Tailwind 扫描的是源码里的字面量类名,view! 里的 class="..." 和 class!("...") 都能扫到,运行时拼出来的类名扫不到。

font:Fontsource 是远程资产

topcoat-font 的 font! 宏描述一组 @font-face 规则,topcoat::font::link(font: ...) 输出样式表链接,router 的扩展把字体 CSS 挂成路由。url(...) 里可以写一个 Asset,字体文件就进资产包、由应用自己服务。

Fontsource 集成(font-fontsource feature)在此之上加了一个编译进 crate 的字体目录:topcoat-font 自带一个 584 KB 的 fonts.json,它自己的 build.rs 把它转成 Rust 常量,fontsource_font!(GEIST) 查的就是这份目录,构建时不联网。每个字体的文件来源是 Host:

Host效果
Assetwoff2 作为远程 asset! 进资产包,打包时由 bundler 下载,应用自己服务
JsDelivr页面直接引用 jsDelivr 的地址

地址由 jsdelivr_url 拼出:

format!(
    "https://cdn.jsdelivr.net/fontsource/fonts/{}@latest/{}-{weight}-{}.woff2",
    self.id,
    subset.as_str(),
    style.as_str(),
)

版本是 @latest。和上一页讲的远程资产缓存放在一起看:Host::Asset 时 bundler 按 URL 缓存,第一次下载的版本会一直用下去;换一台机器或清掉 target 目录就可能拿到新版本,topcoat-font 的源码里没有任何 checksum 相关的代码,这些 URL 没有办法校验内容(按源码,未实测)。

icon:构建期下载,宏展开期读取

Iconify 集成也是两段,下载发生在构建期,不是宏展开期:

  1. 用户的 build.rs 里 iconify::BuildConfig::new().icon_set("feather").stage()。stage 从 https://cdn.jsdelivr.net/npm/@iconify-json/<名字>@<版本>/icons.json 下载图标集,缓存在 <target>/topcoat/cache/iconify/(旁边一个 <set>.version 记版本),再拷进 $OUT_DIR/topcoat-icon-iconify。
  2. iconify::include!("feather") 和 iconify_icon! 在宏展开时读 OUT_DIR 里的 JSON,生成 IconData 常量:视口加 SVG 正文,渲染成内联 <svg>,不产生额外请求。图标名写错是编译错误。

没写 build.rs 就用 include!,宏报「OUT_DIR is not set: Iconify icon sets are staged by a build script」。图标数据编进二进制,不走资产包。

topcoat dev

topcoat dev 把「改代码 → 重新构建 → 打包资产 → 重启进程 → 刷新浏览器」串成一个循环。主循环在 dev.rs,是一个 select!,分支是:Ctrl+C、构建完成、应用进程退出、文件变化、键盘上按了 r。

sequenceDiagram
  participant W as 文件监听
  participant C as topcoat dev
  participant B as cargo build + bundler
  participant A as 应用进程
  participant BS as 广播服务器
  participant Br as 浏览器
  W->>C: 文件变化(防抖合并)
  C->>B: 取消进行中的构建,重新构建
  C->>BS: rebuilding
  B-->>C: 可执行文件路径
  alt 可执行文件没变
    C->>BS: up-to-date,不重启
  else 变了
    C->>A: 关掉旧进程
    C->>A: 启动新进程,环境变量 TOPCOAT_DEV_URL、HOST、PORT
    A->>BS: notify_ready:WebSocket 发 ready
    BS->>Br: reload
    Br->>A: 重新取页面,morph 进 DOM
  end

几个细节:

  • 广播服务器绑在 127.0.0.1,端口从 59039 起往上试 100 个,跨重建一直活着,浏览器连在它上面,不受应用重启影响。
  • 应用进程由 CLI 启动,带三个环境变量:TOPCOAT_DEV_URL(广播服务器地址)、HOST、PORT。topcoat::start 读后两个;serve_until 一开始调用 dev::notify_ready,只在 TOPCOAT_DEV_URL 存在时连上 <dev url>/ws 发一条 ready <addr>。
  • 页面里放 topcoat::dev::script(),开发时输出 <script src="<dev url>/dev.js">,生产环境(没有那个变量)什么也不输出。dev.js 重建成功后重新取页面并 morph,表单状态和 identity 没变的 signal 保留;脚本、base URL、doctype 变了才整页刷新。
  • 没有热补丁。每次都是完整的 cargo build 加进程重启;构建失败时旧进程继续服务。
  • 构建结束后比较可执行文件的「戳」,没变就不重启(打印 no changes; application up to date)。

实测:改了资产文件,旧 URL 变 404

最后一条在 0.10.0 里有个坑。用上一页的 ast 程序跑 topcoat dev(PORT=38205),然后只改 hello.txt 的内容:

  topcoat dev server
  watching for file changes...
  press r to reload
  ready on http://127.0.0.1:38205
  no changes; application up to date
== before
<a href="/_topcoat/assets/hello-84ba19f79faacff0.txt">hello</a>...
old hello: 200
== after edit
<a href="/_topcoat/assets/hello-84ba19f79faacff0.txt">hello</a>...
old hello: 404

改资产文件触发了重建(监听的是整个包目录),bundler 重新打包:新文件 hello-f92b8b0e17f528b6.txt 写进去了,旧文件按「清单里不再有就删掉」的规则被删了。但 asset! 只记录路径不记录内容,可执行文件一个字节都没变,CLI 判定「up to date」,不重启。正在运行的进程手里还是启动时读的旧清单,页面继续输出旧文件名,而这个文件已经不在了。Tailwind 的 CSS 走的是同一条路:改一个类名,build.rs 重新生成 tailwind.css,二进制不变,样式表 404。按 r 强制重启可以恢复。tag 之后的 #524 把清单内容也算进了「戳」,修掉了这个问题。

topcoat fmt

rustfmt 不碰宏的内部,topcoat fmt 补上这一块:用各 grammar crate 的 pretty feature(源码地图里讲过为什么每个宏都拆出 grammar crate),只重排 view! 等宏的内部。在一个 2 空格缩进的文件上实测:

Ok(view! { <div class="card"><p>"Hello, " (name) "!"</p>   <ul> for i in [1,2] { <li>(i)</li> } </ul></div> })
successfully formatted 1 files (1 modified) in 4ms
  Ok(view! {
    <div class="card">
        <p>
            "Hello, "
            (name)
            "!"
        </p>
        <ul>
            for i in [1, 2] {
                <li>(i)</li>
            }
        </ul>
    </div>
})

宏体内部用 4 空格缩进,闭合的 }) 回到了第 0 列,和文件其余部分的 2 空格缩进不一致(这个实验 crate 没有 rustfmt.toml)。不加参数时它只动宏体;加 --rustfmt 先跑一遍 rustfmt 再排宏体,文档也建议把它配成 rust-analyzer 的 overrideCommand。--check 只检查不写回,可以放进 CI。

topcoat ui

topcoat ui 走的是 shadcn/ui 的路子:组件不作为依赖引入,而是把源码拷进项目,之后归你自己改。组件源码打包在 CLI 里(topcoat-ui-registry),不需要联网。在一个空 crate 里:

$ topcoat ui init --theme default
unknown theme `default`; available: neutral
$ topcoat ui init
! This theme uses the Geist font. Set it up with topcoat-font:
  ...
$ topcoat ui add button
+ added button (src/components/button.rs) from topcoat

init 写出 styles.css(主题,作为 Tailwind 的输入)和 components.toml,add 写出 src/components/button.rs(148 行)和 src/components.rs(pub mod button;),并在 components.toml 里记一笔:

[registries.topcoat.components.button]
hash = "sha256:260ed94b12414e4a0e625f75fae88aa51e20954171dcba65cb3cd1937a75ee4d"
file = "src/components/button.rs"

这个 hash 是安装时 registry 里的源码哈希,不是你本地文件的哈希。ui list 拿它和当前 registry 的哈希比,判断「已是最新」还是「registry 有新版本」。往 button.rs 末尾加一行注释之后:

  • ui list 仍显示 ✓ button (installed),本地修改不被察觉;
  • 再 ui add button:src/components/button.rs already exists; pass --overwrite to replace it;
  • ui remove button:- removed button (src/components/button.rs) from topcoat,带着本地修改的文件直接被删了,没有确认。

拷进来的组件改过之后,remove 和 add --overwrite 前先提交到版本控制。

小结

  • tailwind、font、icon 三个集成都建在 asset! 或 build.rs 上:tailwind 在 build.rs 里下载 Tailwind 4.3.2 独立 CLI(默认不校验 sha256),CSS 写进 OUT_DIR 再作为资产打包;iconify 在 build.rs 里下载图标集、宏展开时读取、编进二进制;Fontsource 的字体目录编译进 crate,字体文件是 jsDelivr 上 @latest 的远程资产。三者都要联网,本卷未实测。
  • topcoat dev:广播服务器跨重建常驻,应用进程通过 TOPCOAT_DEV_URL 报到;完整重建加重启,没有热补丁;可执行文件没变就不重启。
  • 0.10.0 里只改资产文件时可执行文件不变、进程不重启,bundler 却删掉了旧文件,页面引用的旧 URL 变成 404(实测;tag 之后 #524 修复)。
  • topcoat fmt 只排宏内部,宏体用 4 空格缩进;topcoat ui 把组件源码拷进项目,记录的是 registry 的哈希,察觉不到本地修改,remove 会直接删掉改过的文件。

💬 关联讨论

发起讨论
加载中...