作者: Zeeko

  • 如何让 MACvLan 网络下的容器与宿主机互联互通

    我在 NAS 上部署了一个 NginxProxyManager 方便管理其他 Self Host 服务的 SSL 证书,为了避免占用宿主机的 80/443 端口,我用了 macvlan 为 NPM 容器分配了一个独立的局域网 IP。

    networks:
      macvlan_network:
        driver: macvlan
        driver_opts:
          parent: <your-network-interface>
        ipam:
          config:
            - subnet: 192.168.100.0/24
              gateway: 192.168.100.1
    
    services:
      npm:
        image: 'jc21/nginx-proxy-manager:latest'
        restart: unless-stopped
        networks:
          macvlan_network:
            ipv4_address: 192.168.100.22
        environment:
          DISABLE_IPV6: 'true'
        volumes:
          - ./data:/data
          - ./letsencrypt:/etc/letsencrypt

    这时,局域网其他的设备就可以通过 192.168.100.22 访问 NPM,但是对于宿主机以及宿主机上其他的容器来说,这个地址是无法访问的

    在咨询了 GPT 之后,它建议了下面的方案:

    [Unit]
    Description=Set up specific macvlan route for a Docker container
    After=network.target
    
    [Service]
    Type=oneshot
    RemainAfterExit=yes
    # Replace <your-network-interface> with the actual interface name
    ExecStart=/usr/bin/ip link add mvlan0 link <your-network-interface> type macvlan mode bridge
    ExecStart=/usr/bin/ip addr add <unused-ip>/24 dev mvlan0
    ExecStart=/usr/bin/ip link set mvlan0 up
    ExecStart=/usr/bin/ip route del 192.168.100.0/24 dev mvlan0
    ExecStart=/usr/bin/ip route add 192.168.100.22 dev mvlan0
    ExecStop=/usr/bin/ip link delete mvlan0
    
    [Install]
    WantedBy=multi-user.target

    默认情况下的问题:

      • 当使用 macvlan/ipvlan 时,Docker 容器会直接连接到物理网络接口
      • 但是宿主机无法通过物理接口直接与这些容器通信,因为物理接口不会处理宿主机到 macvlan 接口的流量

      创建 mvlan0 的作用:

      sudo ip link add mvlan0 link <your-network-interface> type macvlan mode bridge
      sudo ip addr add <unused-ip>/24 dev mvlan0
      sudo ip link set mvlan0 up
      • 这实际上是在宿主机创建了一个新的网络接口 mvlan0
      • 这个接口也是一个 macvlan 接口,与 Docker 容器使用相同的技术
      • mvlan0 接口可以看作是宿主机在 192.168.100.0/24 网段的一个”入口”

      通信流程:

      宿主机 -> mvlan0 (unused-ip) -> 物理网卡 -> Docker 容器 (192.168.100.22)
      • 当宿主机要访问 192.168.100.22 时
      • 数据包会通过 mvlan0 接口发出
      • 由于 mvlan0 和容器的网络接口都是 macvlan 类型,它们可以在同一层次通信

      类比解释:

        • 可以把这个设置想象成宿主机也”加入”到了容器所在的网络
        • mvlan0 就像是宿主机的一个”虚拟网卡”,使它能够与容器在同一个网络层面通信

        为什么需要这样:

          • 这种设置避免了常规的网络栈限制
          • 提供了一个直接的通信路径
          • 保持了网络隔离性的同时又允许必要的通信

          这就是为什么添加这个 macvlan 接口可以让宿主机访问容器的原理。这种方法既保持了容器网络的独立性,又提供了必要的访问路径。

        1. 2024 家庭网络设备大升级 网络篇

          This entry is part 2 of 9 in the series homelab 历险记

          网络设备硬件到齐之后,就需要重新配置网络了,在我的使用场景中,家庭网络除了基本的上网之外需要承担两个额外的功能:

          • 全局透明代理
          • 允许电脑、手机远程访问本地网络服务

          我选择的方案是使用 N100 小主机运行一个 OpenWRT 虚拟机,并将两个物理网卡分配给它,以实现更加高级的网络配置。

          怎么连网线

          因为家宽的下行带宽只有 300M,所以就用开发商自带的超五类网线连接光猫跟 N100 小主机的千兆 WAN 口。

          N100 小主机的 2.5G LAN 口跟一台 TP-Link 路由器连接,路由器设置有线 AP 模式,这样全屋所有的流量都由虚拟机软路由处理,用 openwrt-passwall2 实现全局透明代理功能。

          QNAP NAS 通过网线连接到 TP-Link 路由器,不过由于路由器只有千兆网口,所以 QNAP 的2.5G 网口完全是性能过剩的。

          N100 上再用一个有线网卡连接到 TP-Link 路由器,给宿主机联网使用,这个有线网卡配置了一个静态 IP,方便在软路由挂掉的时候访问管理面板排查故障。

          其他的设备都用无线网络连接,因为户型小,所以单个 TP-Link 路由器的 5GHz WiFi 完全可以覆盖主要的视频消费设备。至于厨房、阳台等犄角旮旯的物联网设备,2.4GHz WiFi 也能提供足够的覆盖度。

          怎么从任意的外部网络访问家里的局域网

          很多 homelab 玩家会选择公网 IP 搭配 DDNS 的方案,但对我来说,这种方案会显著提高局域网的安全风险 —— 我并不是一个 OpenWRT 的运维高手,也完全不会使用 iptables/nft ,如果有人通过公网 IP 侵入了局域网,我大概率很难及时地发现问题。

          我的方案是 zerotier + 拥有公网 IP 的 VPS + singbox。外网设备通过 singbox 客户端连接 VPS 上的 singbox 服务端,然后通过路由规则将发往局域网的流量通过 zerotier 转发到家庭网络的 OpenWRT 上,以此实现远程访问。

          首先,将局域网中的 OpenWRT 跟 VPS 都加入同一个 zerotier 网络。OpenWRT 加入 zerotier 比较复杂,建议参考 openwrt 的文档配置。

          在 OpenWRT 上成功加入 zerotier 网络后,就可以看到下面的接口以及防火墙配置:

          然后在 VPS 上添加一条路由规则:

          ip route add 192.168.100.0/24 via 10.2.2.233
          # 10.2.2.233 就是 OpenWRT 在 zerotier 网段中的 IP

          接着其他设备在外部网络下通过 singbox 连接到 VPS,并且添加下面的 singbox 路由规则:

          "route": {
            "rules": [
              {
                // 家庭局域网网段
                "ip_cidr": [
                  "192.168.100.0/24"
                ],
                "outbound": "vps"
              },
              {
                // 局域网服务专属域名,在公网设置解析到家庭局域网的 Web 网关
                "domain_suffix": [
                  ".n.zeeko.dev"
                ],
                "outbound": "vps"
              },
            ]
          }

          这样不仅可以减少家庭局域网在公网上暴露面,还方便各种设备(笔记本电脑、手机、平板)在各种网络条件下访问家里的局域网设备 —— 只要能连接上部署了 singbox 的 VPS,就可以访问家里的局域网。至于远程访问时的网络连接速度,实测在联通 5G 网络下满足远程播放 20Mbps 码率视频的场景比哔哩哔哩大会员的 4K 视频要强

        2. 2024 家庭网络设备大升级 硬件篇

          This entry is part 1 of 9 in the series homelab 历险记

          趁着双十一,给家里的网络设备做了一个全面的升级。之前家里的网络生态全靠一台单点的旧笔记本电脑支撑,这台笔记本运行着免费版的 PVE,跑着下面三台虚拟机:

          • 软路由
          • Docker Host:运行 Self-host 服务
          • NAS:OpenMyVault

          这次大出血,买了一台双网口的 N100 小主机跟 QNAP TS-464C。由这两台设备来分担之前一台笔记本电脑的全部工作:

          功能设备
          软路由N100 小主机
          Docker HostN100 小主机
          NASQNAP
          多媒体服务N100 小主机

          QNAP 也算得上是老牌网络存储设备提供商了,所以我对存储、备份、同步相关的全部需求都计划由 QNAP 来提供,这样可以尽可能保证数据安全。

          N100 小主机有多个网口,且出厂没有安装操作系统,用来折腾软路由跟一些 self-host 服务最合适不过了。

          除了这些电脑之外,我还买了一台瓦力 UPS,120W 的输出功率给 N100 跟 QNAP 供电完绰绰有余。

          性能够用吗?

          之前笔记本的配置为 16G 内存搭配 6 代移动平台低压 i7 处理器,在 PVE 的面板上经常看到设备满负载运行。新的 N100 跟 QNAP 虽然只有 8G 内存,但是运行相当数量的服务却都只会占用到一半的资源,CPU 利用率也低了很多。

          N100:安装了 FNOS 并通过虚拟机运行软路由
          QNAP:正在对 NAS 上文件建立搜索索引

          目前体验下来,硬件性能完全够用,甚至还有一些富余。原先非常消耗资源的相册应用 (Photo Prism) 替换成 QNAP 提供的 QuMagie 相册后,不仅图片导入速度变快了,而且也不再是 CPU、内存的消耗大户了。

          存储空间够用吗?

          之前的笔记本上主要用一块使用 ZFS 的 2T NVME 硬盘作为 PVE 的存储空间。但因为几次意外的断电事故,导致 ZFS 出现一些不可挽回的数据错误,为此我折腾了很久也没能修复错误,这也让我下定了决心要搭建一个简单易维护的数据存储系统。

          这次在配置 QNAP NAS 的时候,我选择用两块 4T HDD 组成 Raid1 ,用来保存比较重要的数据:

          在我的配置下,总共 8T 的 HDD 实际可用空间大概就 1.2 T,略大于 OneDrive 上 1T 的云存储空间。

          而 N100 小主机则继续使用之前笔记本拆下来的 2T 固态硬盘作为不重要数据的存储空间,用来存存放一些影片也很足够了。

        3. 如何为 CodeMirror Widget 添加 lint marker

          CodeMirror6 中可以创建 Atomic Rranges 用自定义 Widget 替换文本,从而实现更加丰富的展示效果。但是当文本被 Widget 替换后,lint 功能的 markClass 并不会被添加到生成的 Widget 元素上,这就会导致用户无法在 Widget 上看到 linter 生成的错误标记。

          CodeMirror 并没有提供直接地解决方案,我们需要自己将 lint 结果绑定到 Widget 上。由于 Widget 的渲染时机跟 linter 的运行时间都是黑盒,可以假设这俩都会在编辑器内容更新后被异步并发调用。为了方便将 linter 异步产生的结果同步给 Widget ,可以用 rxjs 的 BehaviorSubject 存储 linter 的结果。

          import { Diagnostic } from '@codemirror/lint';
          import { BehaviorSubject } from 'rxjs';
          
          const diagnostics$ = new BehaviorSubject<Diagnostic[]>([]);

          在实现 linter 时,除了直接返回 Diagnostic 外,还需要将结果写入 diagnostics$

          const invalidMetric = linter(async view => {
            const diagnostics: Diagnostic[] = await yourLinter(view);
            diagnostics$.next(diagnostics);
            return diagnostics;
          });

          接下来,我们在创建 Widget 时就可以订阅 Diagnostic 的变化来更新 UI 状态:

          class PlaceholderWidget extends WidgetType {
            constructor(string, private from: number, private to: number) {
              super();
            }
          
            toDOM(view: EditorView): HTMLElement {
              const span = document.createElement('span');
              span.innerHTML = renderYourWidget(view);
              // @ts-expect-error add destroy$ to span
              span.destory$ = diagnostics$.subscribe(diagnostics => {
                const error = diagnostics.find(d => d.from >= this.from && d.to <= this.to);
                if (error) {
                  span.classList.add('cm-widget-error');
                } else {
                  span.classList.remove('cm-widget-error');
                }
              });
              return span;
            }
          
            destroy(el: HTMLSpanElement) {
              // @ts-ignore
              if (el.destory$) {
                // @ts-ignore
                el.destory$.unsubscribe();
              }
            }
          }

          注意 PlaceholderWidget 的构造函数,我们需要在创建 Widget 的时候,传入 Widget 的插入位置(fromto)。

          const placeholderMatcher = new MatchDecorator({
            regexp: /#[0-9A-Za-z_]+/g,
            decorate: (add, from, to, match) => {
              const deco = Decoration.replace({
                widget: new PlaceholderWidget(from, to),
              });
              add(from, to, deco);
            },
          });
          const placeholders = ViewPlugin.fromClass(class {
            placeholders: DecorationSet;
          
            constructor(view: EditorView) {
              this.placeholders = placeholderMatcher.createDeco(view);
            }
          
            update(update: ViewUpdate) {
              this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders);
            }
          }, {
            decorations: instance => instance.placeholders,
            provide: plugin => EditorView.atomicRanges.of(view => {
              return view.plugin(plugin)?.placeholders || Decoration.none;
            }),
          });

          最终的效果如下:

        4. 有多少「黄牛」在抢四川消费券?

          2024年9月,四川省财政厅投入了 3 亿资金用来向市民发放家装消费券

          在9月27日消费券被领取完后的 30 分钟,我在闲鱼上找到了跟四川家装消费券相关的87条搜索结果:

          补贴金额数量小计
          4k416k
          2k48k
          1.5k1319.5k
          0.9k1715.3k
          0.6k148.4k
          总计5267.2k

          在领取完后的一小时,相关搜索结果有 158 条。据此粗略估计,每天在闲鱼上被转卖的消费券大概价值 20 万元。

          根据官方的消费券发放安排,价值 3 亿的消费券会在 9 月 26 日到 10 月 31 日中的 31 天发放,假设能够全部发完,每天应该发放价值 967 万元的消费券。

          如果只有闲鱼上有黄牛,那么 9 月 27 日这天的黄牛含量大概在 2% (20/967≈2.07%)。

        5. DRAFT: pre-bundle dependencies for vite

          背景

          如果我不想每次构建 SPA 的时,把项目的依赖也一并重新构建一遍,我应该怎么做?
          预打包项目依赖,当项目依赖长久没有发生变更时,不再重新 bundle node_modules。

          解决方案

          类似 Webpack DLL plugin,但是要更加智能。

          只预打包项目中多次使用的依赖包。
          同时支持 build/serve 两种模式。

          实现细节——预打包部分:

          用正则表达式找出项目中所有的 import 语句
          用单独的 vite config 配合 preBundle 插件为第三方包生成预打包文件

          实现细节——使用预打包文件:

          build 模式,这里涉及 rollup 相关的插件逻辑,需要让 rollup 将预打包的模块 id 解析为外部依赖,并且将预打包文件作为 prebuilt-chunk 输出。

          dev 模式,这里涉及 vite 跟 esbuild 相关的逻辑。
          由于我们并不会为所有的第三方依赖生成预打包文件,所以存在某些 optimizeDeps 模块引用预打包模块的情况。
          vite 会使用 esbuild optimize deps,所以需要配合 esbuild 插件处理。

          如何在导入方使用 commonjs 导出的 named export ?

          在预打包阶段, rollup 不会为 cjs entry 生成 named export,当我们在导入预打包文件时,需要对这些被转换成 esm 的 cjs 做一些额外处理。

          需要自己实现一个 transform hook:

          // input
          import { useState } from 'react';
          // ouput
          import React from 'react';
          const useState = React.useState;
          

          反思

          预打包多次使用的依赖会为每个依赖都创建一个 bundle 文件,这可能会让浏览器加载页面的时候需要发送更多的请求。

          但是考虑到这些预打包文件可能很少更新,浏览器本地缓存可以起到很好的效果。如果浏览器跟服务器之间使用的是 http2 协议,这些请求似乎也不太算是问题?
          如果要从根本上解决这个问题,还得靠 web bundle。

          实现 merged exports

          https://github.com/tc39/proposal-module-declarations?tab=readme-ov-file

          @ant-design/icons 中提供了很多 icons, 如果为每个 icon 都创建一个 prebundle chunk, 那么 output 目录中就会出现上千的小文件。

          要避免这种情况,需要实现合并 exports 的功能。

          // vite.config.mts
          
          plugins: [
            prebundleReference({
              merge: {
                '<ruleName>': ['<module-id-prefix>', '@ant-design/icons']
              }
            })
          ]
          

          以上的配置会在 transform 环节产生如下的代码:

          // pre-bundle-merged-<ruleName>.mjs
          import * as __at__ant_design_icons from '@ant-design/icons';
          import * as __at__ant_design_icons_Foo from '@ant-design/icons/Foo';
          export const __ns_at__ant_design_icons = __at__ant_design_icons;
          export const __ns_at__ant_design_icons_Foo = __at__ant_design_icons_Foo;
          

          对应的 manifest 部分

          [
            {
              "moduleId": "@ant-design/icons",
              "moduleFilePath": "pre-bundle-merged-<ruleName>.mjs",
              "exports": [
                "default",
                "__moduleExports"
              ],
              "exportAs": "__ns_at__ant_design_icons",
              "isCommonJS": false
            },
            {
              "moduleId": "@ant-design/icons/Foo",
              "moduleFilePath": "pre-bundle-merged-<ruleName>.mjs",
              "exports": [
                "default",
                "__moduleExports"
              ],
              "exportAs": "__ns_at__ant_design_icons_Foo",
              "isCommonJS": false
            },
          ]

          当在 reference 插件中使用时,需要转换 import 代码:

          // helper
          
          export function _prebundle_merge_get_default(moduleLike) {
            return moduleLike.default ?? moduleLike;
          }
          
          export function _prebundle_merge_get_named(moduleLike, name) {
            if(name in moduleLike){
              return moduleLike[name];
            }
            if(moduleLike.default && name in moduleLike.default) {
              return moduleLike.default[name];
            }
          }
          // default import
          import Foo from '@ant-design/icons/Foo';
          // =>
          import { __ns_at__ant_design_icons_Foo as __ns_at__ant_design_icons_Foo$1 } from '@ant-design/icons/Foo';
          const Foo = _prebundle_merge_get_default(__ns_at__ant_design_icons_Foo$1);
          
          
          // named import
          import { Foo } from '@ant-design/icons';
          // =>
          import { __ns_at__ant_design_icons_Foo as __ns_at__ant_design_icons_Foo$2 } from '@ant-design/icons/Foo';
          const Foo = _prebundle_merge_get_named(__ns_at__ant_design_icons_Foo$2, 'Foo');
          
          // ns import
          import * as Icons from '@ant-design/icons';
          // =>
          import { __ns_at__ant_design_icons_Foo as __ns_at__ant_design_icons_Foo$3 } from '@ant-design/icons/Foo';
          const Icons = __ns_at__ant_design_icons_Foo$3;

          prebundle merge transform 的逻辑应该在 commonjs 转换之后,因为 commonjs transform 假定 module 只会导出 { default: blabla }

          // input
          import {useCallback} from 'react';
          
          // transform commonjs
          import React from 'react';
          const useCallback = React.useCallback;
          
          // transform prebundle merge
          import { __ns_react as __ns_react$1 } from 'react';
          const React = _prebundle_merge_get_default(__ns_react$1);
          const useCallback = React.useCallback;

          好像也可以将 commonjs transform 跟 prebundle merge transform 合并?

          [
            {
              "moduleId": "react",
              "moduleFilePath": "pre-bundle-merged-<ruleName>.mjs",
              "exports": [
                "default"
              ],
              "exportAs": "default",
              "isCommonJS": true
            }
          ]
          import React, {useCallback} from 'react';
          
          // =>
          import { default as __ns_react$1 } from 'react';
          const React = _prebundle_merge_get_default(__ns_react$1);
          const useCallback = _prebundle_merge_get_named(__ns_react$1.'useCallback');

          接着,在 resolve 阶段,只需要将模块 id 都替换成 <path-to>/<moduleFilePath>

          预打包依赖项,真的可以提高构建效率吗?

          在我的项目中,第三方依赖的体积远大于实际的代码量,每次启动 dev server 或者构建 production artifacts,都需要消耗很多时间在转换 node_modules 代码上。预打包依赖就可以很好的解决这个问题。

          另外,配合 nx 的共享缓存,这个项目的所有贡献者、CI 流水线,都可以享受到预打包带来的提升。

          为啥不能用 UMD 格式提供 pre-bundle ?

          rollup 不支持为 umd bundle 生成 common shared chunk

        6. 如何选购冰箱

          冰箱是柜子,冰柜是箱子

          影响冰箱摆放位置的产品参数

          作为家中体积最大的电器,找个合适的位置放冰箱很关键。影响冰箱摆放空间的参数有两个:

          • 散热方式
          • 开门方式

          两侧散热的冰箱需要预留更多的空间帮助全年无修的冰箱散热,通常需要在冰箱的左右两侧以及后侧各预留 10cm 左右的空间。

          底部散热或者顶部散热的冰箱对空间的需求比较小,适合摆放在比较狭小的空间。

          开门方式也会影响冰箱的摆放,虽然冰箱叫「箱」,但是开门方式跟柜子类似,主要使用抽屉跟平开门。所以在摆放冰箱的时候也应该像摆放柜子一样,考虑门板、抽屉跟其他家具的冲突。如果空间比较狭小,可以选择 90° 的平开门。

          另一个节省冰箱摆放空间的技巧是把冰箱插座的位置调整到冰箱的侧面,或者使用内嵌式插座。这样可以为冰箱节省 3 ~ 4cm 的深度空间。

          影响收纳能力的产品参数

          作为柜子,冰箱当然越能装越好,商家往往只会商品详情页上写明容量,例如,500L,但是我们往冰箱中存放的只会是固体。所以容量只能作为参考,真正影响使用体验的是冰箱的收纳方式,对应的产品参数是门款式。

          门款式通常对冷冻区的格局影响比较大,大家可以按照自己最常屯的物品选择适合的冷冻区格局。

          影响使用体验的产品参数

          串味是影响冰箱使用体验的一个常见问题,从冰箱的角度解决这个问题有两种方式:

          • 双循环风道
          • 净味系统

          现在多数冰箱采用风冷降温,冷空气会在冷藏区、冷冻区之间流动,只有单循环风道的冰箱会有跨区窜味的问题。一些冰箱通过引入双循环风道,将冷藏、冷冻区之间物理隔离开,从而避免串味的问题。

          净味系统则是引入了类似空气净化器的模块,也能在一定程度上减少冰箱的异味,异味浓度低了,串味的影响也会降低。

          影响保鲜功能的产品参数

          保鲜可以从两个层面理解 —— 防止腐败、防止风干

          为了防止腐败,我们应该尽量选择带有杀菌能力检测证书的冰箱 只声称可以杀菌但不提供证书的都看作没有杀菌能力,通过抑菌、灭菌来降低食物污染风险。

          为了防止冷风将新鲜蔬果吹干,可以选择带有湿度调节区域的冰箱,减少干冷空气对果蔬的影响。

          不过防止腐败跟防止风干是有些冲突的两个功能,潮湿的有氧环境有利于微生物繁殖,所以, 最好还是减少新鲜蔬果的存放时间,或者将蔬果单独分区存放。

          产品关键参数速查表

          • 摆放位置
            • 长宽高
            • 散热方式
            • 开门方式
            • 插座位置
          • 收纳能力
            • 容量
            • 门款式
          • 防串味
            • 是否双循环
            • 净味系统
          • 保鲜
            • 杀菌、抑菌能力证书
            • 果蔬保鲜区

        7. 给 Email 写样式真的很难

          虽然 IE 退出了浏览器的历史舞台,但是 Windows 平台上的 Outlook 依然继承了 IE 的衣钵,继续在邮件客户端界发光发热。

          This ranks email clients based on their support among the 298 HTML and CSS features listed on Can I email.

          最好用内联样式

          很多情况下,使用外部样式表并不靠谱,比如 GMail 以及 Outlook 就会在特定情况下移除邮件 HTML 中的 style 标签,一个更保守的做法是使用元素上的内联样式。对于简单的通知邮件来说,完全够用了。

          如果嫌手搓样式很麻烦,可以先用 TailWind CSS 编写,然后让 ChatGPT 帮忙转换成内联样式。

          避免使用 base64 内联图片

          虽然 Base64 图片 URL 很简单易用,但是很多邮件客户端会把它们当作外部图片一样默认屏蔽。

          一个对展示效果更加友好的做法是通过 cid 引用内联图片,这时,图片会被作为邮件的附件一起发送给收件人,如果不希望正文图片被客户端识别为可下载的附件,则需要将附件的 Content-Disposition 设置为 inline,同时,还需要设置正确的 Content-Type

          Quick summary

          1. Inline only attachments: use multipart/related
          2. Non-inline only attachments: use multipart/mixed
          3. Inline and non-inline attachments use multipart/mixed and multipart/related
          mime – HTML-Email with inline attachments and non-inline attachments – Stack Overflow

          我的 Email 模板工作流

          考虑到 EMail HTML 本身的复杂性,为了简化 Email 模板的开发,我采用了下面的工作流:

          首先,使用 degit 初始化一个集成了 TailWind CSS 跟 Vite 的项目模板:

          npx degit kometolabs/vite-tailwind-nojs-starter email-template

          修改其中的 vite.config.mjs:

          import { resolve } from "path";
          import { defineConfig } from "vite";
          import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
          
          function optimizeSvgPlugin() {
            if (!!process.env.OPTIMIZE_IMAGES) {
              return ViteImageOptimizer({
                svgo: {
                  plugins: [{ removeViewBox: false }, { removeDimensions: true }],
                },
              });
            }
          }
          
          export default defineConfig(() => ({
            build: {
              outDir: "../dist",
              emptyOutDir: true,
              // avoid inlining images
              assetsInlineLimit: () => false,
              rollupOptions: {
                input: {
                  page1: resolve(__dirname, "src", "page1.html"),
                  page2: resolve(__dirname, "src", "page2.html"),
                },
                output: {
                  // avoid hash in assets filename
                  assetFileNames: (assetInfo) => {
                    const info = assetInfo.name.split(".");
                    let extType = info[info.length - 1];
          
                    // Explicitly handle image files
                    if (/png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) {
                      extType = "img";
                      // Return the original filename without hashing
                      return `assets/${extType}/[name][extname]`;
                    }
          
                    // For other asset types, you can keep the default hashing behavior
                    return `assets/${extType}/[name]-[hash][extname]`;
                  },
                },
              },
            },
            root: "src",
            plugins: [optimizeSvgPlugin()],
          }));

          在编写 HTML 时需要手动创建一个引入 TailWind CSS 的 style 标签:

          <style>
          @tailwind base;
          @tailwind components;
          @tailwind utilities;
          </style>

          接下来就可以按照常规的方法用 HTML 画页面了,不过需要注意的是,大部分邮件客户端对 flex-box 的支持都比较差,在编写样式时需要注意。

          完成了 HTML 页面以及样式后,就可以借助 ChatGPT 将 TailWind 转换成内联样式的代码。最后,借助下面的脚本,即可将构建出来的 HTML 文件转换成 ejs 模板:

          // convert all `{{ foo }}` to `<%= foo %>`
          
          function convertToEjs(content: string) {
            return content.replace(/{{\s*(\w+?)\s*}}/g, "<%= $1 %>");
          }
          
          /**
           * Convert /assets/img/foo-bar.svg to cid:foo-bar
           * @param content
           */
          function toCidReference(content: string): string {
            return content.replace(/\/assets\/img\/([a-zA-Z0-9-)]+?)\.(svg|png|jpg|jpeg)/g, "cid:$1");
          }
          
          const files = await glob(dir + "/**/*.html");
          
          for (const filename of files) {
            const content = await fs.readFile(filename, "utf-8");
            const dirOfFile = path.dirname(filename);
            const newContent = toCidReference(convertToEjs(content));
            const newFilename = path.join(
              dirOfFile,
              path.basename(filename, ".html") + ".ejs",
            );
          
            await fs.writeFile(newFilename, newContent);
            echo(chalk.green(`Converted ${filename} to ${newFilename}`));
          }

          用来生成邮件模板的命令行操作如下:

          #!/usr/bin/bash
          OPTIMIZE_IMAGES=true yarn build
          cp ./dist/assets/img/* ./assets/img/
          npx tsx ./scripts/to-ejs-template.ts --dir ./dist

          参考链接

          How to Embed Images in Your Emails (CID, HTML Inline & More) | SendGrid

          mime – HTML-Email with inline attachments and non-inline attachments – Stack Overflow

        8. 如何为博文自动生成可读的 Permalink

          WordPress 默认提供了一个比较适合英语文章的 Permalink 生成机制,如果你跟我一样使用中文标题,那自动生成的 Permalink 中就会包含汉字,这对 URL 而言并不友好。之前我都会手动给每篇博文翻译一个英文标题,再将其 slugify,做得多了就会觉得繁琐,于是我开发了一个 API,可以用来帮助自动化地完成这个过程。

          API 的调用方式如下,大家可以免费使用。

          GET https://blogkit.apps.gianthard.rocks/api/v1/slugify?t=如何科学饲养母猪
          
          HTTP/2 200 OK
          server: nginx
          date: Mon, 19 Aug 2024 10:01:06 GMT
          content-type: application/json; charset=utf-8
          age: 189318
          cache-control: public,max-age=31536000
          content-length: 24
          x-http2-stream-id: 3
          
          "scientific-sow-rearing"

          要在 WordPress 中自动调用的话,需要在 WordPress 管理页面添加一些代码:

          function suggest_slug_from_api( $slug, $post_id, $post_status, $post_type, $post_parent, $original_slug ) {
              // Only apply for new posts or when the slug is empty
              if ( empty($slug) ) {
                  $post = get_post($post_id);
                  $title = $post ? $post->post_title : '';
          
                  if ( empty($title) ) {
                      return $slug; // Return original slug if no title
                  }
          
                  $api_url = 'https://blogkit.apps.gianthard.rocks/api/v1/slugify';
          
                  // Encode the title for use in URL
                  $query = http_build_query(array('t' => $title));
                  $url = $api_url . '?' . $query;
          
                  // Make the API request
                  $response = wp_remote_get($url);
          
                  // Check if the request was successful
                  if ( !is_wp_error($response) && wp_remote_retrieve_response_code($response) == 200 ) {
                      $body = wp_remote_retrieve_body($response);
                      $suggested_slug = json_decode($body, true);
          
                      // If we got a valid slug, use it
                      if ( $suggested_slug && is_string($suggested_slug) ) {
                          return $suggested_slug;
                      }
                  }
              }
          
              // If anything goes wrong or if the post already has a custom slug, return the original slug
              return $slug;
          }
          add_filter( 'wp_unique_post_slug', 'suggest_slug_from_api', 10, 6 );
          

          我使用的是 WP Code 插件,用这个插件管理 PHP 代码片段非常方便,免费版也很够用。

          https://wordpress.org/plugins/insert-headers-and-footers/
          注意只在 Admin 页面上启用这个片段

          上面的代码工作完成后,WordPress 就会在草稿保存后自动生成 URL slug 。需要注意的是,编辑页面的前端可能不会及时更新 URL slug 预览,但是发布后就会使用通过 API 生成的 URL Slug。

        9. 前端处理响应式图片

          MDN: Responsive images 介绍了在前端实现响应式图片的几种方法:

          • 为不同尺寸的屏幕加载不同大小的图片(srcset 配合 sizes
          • 固定图片大小,为不同 DPI 的屏幕加载不同 DPI 的图片(srcset
          • 为不同尺寸的屏幕加载不同的图片(picture 配合多个 source

          WordPress 采用 srcset/sizes 方案实现响应式图片,默认的设置为 sizes="(max-width: {{image-width}}px) 100vw, {{image-width}}px"。当页面宽度小于图片原始宽度时,以页面宽度(100vw)作为图片的显示宽度,当页面宽度大于图片宽度时,总会以图片原始宽度作为显示宽度。但实际上,不是所有的图片都会以原始宽度在页面上展示,由于排版样式的影响,图片的展示宽度往往跟原始宽度不一致。

          为了解决这个问题,需要使用 Enhanced Responsive Images 插件,这个插件会基于当前主题的 Content Width, Wide Width 为图片元素设置 srcset/sizes,相较于默认的实现,可以为大屏设备在图片宽度小于窗口宽度时提供更加合适的缩略图尺寸。

          Performance Lab 提供的 Enhanced Responsive Images 插件

          启用后的效果如下图:

          可以看到,虽然 img 元素的大小只有 800×312 px,但是浏览器选择了展示原图(1341 x 523 px),这是因为我的屏幕启用了 HiDPI,1 css px 约等于 2 显示器 px,这种情况下,浏览器使用更大尺寸的图片是合理的。