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 | 效果 |
|---|---|
Asset | woff2 作为远程 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 集成也是两段,下载发生在构建期,不是宏展开期:
- 用户的
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。 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会直接删掉改过的文件。