<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>4Ark</title><description>Art and beauty can be created on a computer.</description><link>https://4ark.me/</link><item><title>每周轮子之 server-only：一个空包背后的“毒药”</title><link>https://4ark.me/posts/2025-12-24-weekly-npm-packages-03/</link><guid isPermaLink="true">https://4ark.me/posts/2025-12-24-weekly-npm-packages-03/</guid><description>server-only 如何通过下毒在 RSC 中防止服务端代码泄露</description><pubDate>Wed, 24 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;三年前写过两篇&lt;a href=&quot;https://4ark.me/tags/%E6%AF%8F%E5%91%A8%E8%BD%AE%E5%AD%90/&quot;&gt;《每周轮子》&lt;/a&gt;系列文章，讲如何从零实现一个日常都在用的 npm 包。其中的乐趣就是看看自己实现和别人有什么不一样，同时也能开阔视野。&lt;/p&gt;
&lt;p&gt;现在迎来第三篇，这次我们将目光转向 React Server Components（说到转，旧手机可以找…&lt;/p&gt;
&lt;p&gt;毕竟众所周知，这个月初 Next.js 圈子大型翻车现场，先后爆出几个 CVE，最出名的当属 &lt;a href=&quot;https://github.com/msanft/CVE-2025-55182&quot;&gt;CVE-2025-55182&lt;/a&gt;，据闻我们的大善人 Cloudflare 两周崩两次也是因为它，逼得我几次在群里嘴臭。&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;（PHP 有一个很经典的漏洞叫作 PHP Object Injection）&lt;/p&gt;
&lt;p&gt;当我知道这个 CVE 时，上 fofa 随便挑选一位幸运倒霉蛋进行测试，很容易就利用上了：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;结果没过几天，又来一个 &lt;a href=&quot;https://github.com/kimtruth/CVE-2025-55183-poc&quot;&gt;CVE-2025-55183&lt;/a&gt;，这是一个会泄露 RSC 组件源码的漏洞，说到源码泄露，这就要引出本文的主角了：server-only。&lt;/p&gt;
&lt;p&gt;几乎所有最佳实践都告诉我们：在服务端代码顶部加上 &lt;code&gt;import &apos;server-only&apos;&lt;/code&gt;，可以避免源码泄露。&lt;/p&gt;
&lt;p&gt;今天突发奇想，想看看它是怎么实现的。&lt;/p&gt;
&lt;h2&gt;v1 Runtime Check&lt;/h2&gt;
&lt;p&gt;按照惯性思维，如果让我实现一个 server-only，我第一反应是写个运行时检查。既然代码不能在浏览器跑，那我就检测 window 对象嘛。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 我脑补的 server-only
if (typeof window !== &quot;undefined&quot;) {
  throw new Error(&quot;❌ 严重安全错误：服务端模块泄露到了客户端！&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这看起来很合理，对吧？&lt;/p&gt;
&lt;p&gt;但这完全是错的。&lt;/p&gt;
&lt;p&gt;Dan 在 &lt;a href=&quot;https://overreacted.io/how-imports-work-in-rsc/&quot;&gt;How imports work in RSC&lt;/a&gt; 里讲得很清楚：哪怕你声明 &apos;use server&apos;，但只要构建工具（Webpack/Turbopack）看到你 import 了，它就会把代码打包进去。&lt;/p&gt;
&lt;p&gt;也就是说，用上面这种方式，虽然页面报错了，但你的服务端代码依然存在于浏览器下载的 JS 文件里。只要别人右键查看源码，依然泄露。&lt;/p&gt;
&lt;p&gt;典型“脱裤子放屁”——甚至更离谱，因为你觉得你安全了。&lt;/p&gt;
&lt;h2&gt;v2 Poison Pill&lt;/h2&gt;
&lt;p&gt;既然 Runtime 检查太晚了，我们必须在 Build Time（构建时）拦截它。&lt;/p&gt;
&lt;p&gt;那 server-only 是怎么做的，如果你点开它的 npm 页面，你会发现它什么都没有，没有 README.md 甚至没有 git repo，你甚至都不知道它的作者是谁。&lt;/p&gt;
&lt;p&gt;然后我打开 code 一看就这么简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server-only
├── index.js
├── empty.js
└── package.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;（这不比传说中的 is-odd 还简陋？）&lt;/p&gt;
&lt;p&gt;里面的文件就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// index.js
throw new Error(
  &quot;This module cannot be imported from a Client Component module. &quot; +
    &quot;It should only be used from a Server Component.&quot;
);

// empty.js
// 没错这个就是空的
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;细究之后才发现，这是一种毒药模式，简单说就是利用 Node.js 的条件导出实现一个精分 npm 包：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;server-only&quot;,
  &quot;description&quot;: &quot;This is a marker package to indicate that a module can only be used in Server Components.&quot;,
  &quot;files&quot;: [&quot;index.js&quot;, &quot;empty.js&quot;],
  &quot;main&quot;: &quot;index.js&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;react-server&quot;: &quot;./empty.js&quot;,
      &quot;default&quot;: &quot;./index.js&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;react-server&lt;/code&gt; 是什么？很明显它不是 Node.js 的规范，而是 React 团队定义的一种规范，代表了 RSC 的运行时环境。&lt;/p&gt;
&lt;p&gt;这也是给所有第三方库用的一种标准，所以在 Next.js (或者其他 RSC 框架) 的构建时，它实现会跑两条 Pipelines：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Server Bundle：在这个流水线下，所有 import 都会优先寻找 package.json 里的 react-server 入口。&lt;/li&gt;
&lt;li&gt;Client Bundle：正常跑&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当 server-only 这个包被服务端引入，它就是一个空文件，被客户端引入它就报错，这就是毒药。&lt;/p&gt;
&lt;h2&gt;还有高手？&lt;/h2&gt;
&lt;p&gt;我在研究过程中，顺藤摸瓜看了下 Next.js 仓库里的 Issue &lt;a href=&quot;https://github.com/vercel/next.js/issues/71071&quot;&gt;#71071&lt;/a&gt;，发现事情还没这么简单。&lt;/p&gt;
&lt;h3&gt;1. 作者之谜&lt;/h3&gt;
&lt;p&gt;这个包最早的出处来自这个 &lt;a href=&quot;https://github.com/vercel/next.js/pull/44861/files#diff-852969592b5abf5d5ef83d2b0c6d2e82cc2a4c4abb7e1a2a36cbde20f5544e36&quot;&gt;PR&lt;/a&gt;，其实这个包只是 Next.js 内部使用，所以它什么都没有。&lt;/p&gt;
&lt;h3&gt;2. Tree-shaking 的漏网之鱼&lt;/h3&gt;
&lt;p&gt;你可能会问：如果我只引入了类型呢？&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ClientComponent.tsx
import type { UserType } from &quot;./db-schema&quot;; // 引用了 server-only 的文件
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结论是：安全的。现代构建工具足够聪明，import type 会在编译阶段被移除，根本不会触发 server-only 的解析逻辑。&lt;/p&gt;
&lt;p&gt;但是，如果你写成这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { UserType, dbInstance } from &quot;./db-schema&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即使你在代码里完全没用到 dbInstance，只是想用一下 UserType，构建工具依然会去解析这个文件，然后触发 server-only 的报错。&lt;/p&gt;
&lt;h3&gt;3. npm 包作者应有的觉悟&lt;/h3&gt;
&lt;p&gt;以前我们开发一个 npm 包，package.json 里写个 main 和 module 就完事了。但在 RSC 时代，如果你开发的库（比如一个数据库 ORM 客户端）不希望被误用到前端，你必须手动加上 react-server 的导出条件。&lt;/p&gt;
&lt;p&gt;但如果你很不幸使用了一个没跟上节奏的 npm 包，那你得自觉地贴上 &lt;code&gt;import &apos;server-only&apos;&lt;/code&gt;。&lt;/p&gt;
</content:encoded></item><item><title>一文讲透 Etherpad 插件开发</title><link>https://4ark.me/posts/2025-12-18-etherpad-plugins/</link><guid isPermaLink="true">https://4ark.me/posts/2025-12-18-etherpad-plugins/</guid><description>搞懂 Etherpad 插件开发，看这篇就够了</description><pubDate>Thu, 18 Dec 2025 01:29:00 GMT</pubDate><content:encoded>&lt;h2&gt;Etherpad 是什么&lt;/h2&gt;
&lt;p&gt;Etherpad 是一款基于 Node.js 的开源实时协作编辑器，能让很多人同时在线编辑，团队用它写文章、新闻稿、会议记录或者待办事项都很方便。跟 Google Docs 比，它最大的优点是数据可以自己管，扩展性也很强。&lt;/p&gt;
&lt;p&gt;本文不重复官方文档里那些基础的东西，而是结合实际，好好讲讲怎么开发一个集成了“富文本、组件化内容、多渠道发布”的插件。&lt;/p&gt;
&lt;p&gt;Etherpad 大概长这样：&lt;/p&gt;
&lt;p&gt;
&lt;/p&gt;
&lt;h2&gt;技术架构&lt;/h2&gt;
&lt;p&gt;Etherpad 用的是全栈 JavaScript，靠 Socket.io 做实时通信，还用 OT 算法解决多人同时编辑的冲突。&lt;/p&gt;
&lt;p&gt;前端 (jQuery + Ace2)：页面用 jQuery 搭，编辑器核心是用 iframe 装着的 Ace2（基于 contentEditable 做的），负责接收输入，生成 Changeset。&lt;/p&gt;
&lt;p&gt;后端 (Node.js + UeberDB)：负责管 WebSocket 连接，合并 Changeset，然后广播出去。&lt;/p&gt;
&lt;p&gt;插件系统 (Hook)：系统在关键的地方（比如 padInitToolbar, getLineHTMLForExport）留了 Hook 让你加东西。&lt;/p&gt;
&lt;h2&gt;插件怎么做？&lt;/h2&gt;
&lt;p&gt;官方建议一个功能搞一个 npm 包，但如果要深度定制，这样做太麻烦了。建议建一个插件合集（比如叫 ep_plugins）。&lt;/p&gt;
&lt;p&gt;我做的这个插件集合主要有三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;增强编辑器 (Ace)&lt;/strong&gt;：加了字体颜色、荧光笔、高亮字号、超链接、图片（包括删除）、图片脚注这些功能。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内容组件化&lt;/strong&gt;：用“自定义标签（&lt;code&gt;&amp;lt;ep-*&amp;gt;&lt;/code&gt;）”来放复杂的内容（题图卡片、主题卡片、往期阅读卡片、公众号关注卡片、腾讯视频、文章目录&amp;lt;/ep-*&amp;gt;等等），在编辑器里看着就像可以编辑的文本，导出或者发布的时候再解析成 HTML。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;发布和外部系统整合&lt;/strong&gt;：把 Etherpad 的内容变成 Markdown，再变成不同渠道（微信公众号 / WordPress）的 HTML，通过后端接口发出去，还提供预览和一键复制功能。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;设计思路：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;编辑的时候&lt;/strong&gt;：用 Etherpad 的 attribute（changeset attribution）存样式信息（比如：&lt;code&gt;color=#f13b03&lt;/code&gt;、&lt;code&gt;url=https://...&lt;/code&gt;），然后在 Ace 渲染的时候转成 CSS class 或者 DOM 结构，这样编辑起来更顺手。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;导出/同步的时候&lt;/strong&gt;：从 pad 的 AText（文本 + attribution）生成“增强版 Markdown”，把样式/组件用 &lt;code&gt;&amp;lt;ep-*&amp;gt;&lt;/code&gt; 标签留着；然后用 &lt;code&gt;marked&lt;/code&gt; 和自定义扩展把 &lt;code&gt;&amp;lt;ep-*&amp;gt;&lt;/code&gt; 变成不同渠道的&amp;lt;/ep-xx&amp;gt;&amp;lt;/ep-xx&amp;gt; HTML。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;开发行内样式（拿“字体颜色”举例）&lt;/p&gt;
&lt;h2&gt;行内样式开发流程：&lt;strong&gt;UI 触发 → 写入属性 → 渲染样式&lt;/strong&gt;。&lt;/h2&gt;
&lt;h3&gt;注册 UI 和写入属性 (Client Side)&lt;/h3&gt;
&lt;p&gt;先在工具栏注册按钮，然后监听下拉框的变化，调用 &lt;code&gt;documentAttributeManager&lt;/code&gt; 写入数据。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// static/js/index.js

// 1. 监听工具栏初始化 (Hook: postToolbarInit)
exports.postToolbarInit = (hook, context) =&amp;gt; {
  const toolbar = context.toolbar;

  // 注册下拉框变化事件
  toolbar.registerCommand(&quot;fontColor&quot;, value =&amp;gt; {
    const ace = context.ace;
    ace.callWithAce(
      ace =&amp;gt; {
        // 给当前选区打上 color 属性
        ace.ace_setAttributeOnSelection(&quot;color&quot;, value);
      },
      &quot;fontColor&quot;,
      true
    );
  });
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;将属性映射为 CSS Class (Client Side)&lt;/h3&gt;
&lt;p&gt;Etherpad 默认不认识 &lt;code&gt;color&lt;/code&gt; 属性，需要我们告诉它如何渲染。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// static/js/index.js

// 2. 属性转 Class (Hook: aceAttribsToClasses)
exports.aceAttribsToClasses = (hook, context) =&amp;gt; {
  // 如果属性名是 color，生成 .color__#xxxxxx 的 class
  if (context.key === &quot;color&quot;) {
    return [`color__${context.value.replace(&quot;#&quot;, &quot;&quot;)}`];
  }
};

// 3. 注入 CSS 样式 (Hook: aceInitInnerdocbodyHead)
// 注意：样式必须注入到 ace_inner iframe 中
exports.aceInitInnerdocbodyHead = (hook, context) =&amp;gt; {
  return [
    `
    &amp;lt;style&amp;gt;
      /* 动态匹配所有颜色 class */
      [class*=&quot;color__&quot;] { display: inline; }
      /* 这里通常需要动态生成 CSS，或者使用 CSS 变量方案 */
      .color__f13b03 { color: #f13b03; }
    &amp;lt;/style&amp;gt;
  `,
  ];
};
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;内容组件化（自定义标签 DSL）&lt;/h2&gt;
&lt;p&gt;对于题图、目录、视频等复杂内容，我们使用 &lt;strong&gt;自定义标签（Custom Tags）&lt;/strong&gt; 作为载体。&lt;/p&gt;
&lt;h3&gt;Marked 扩展构建器&lt;/h3&gt;
&lt;p&gt;为了让系统能识别 &lt;code&gt;&amp;lt;ep-toc&amp;gt;&lt;/code&gt; 或 &lt;code&gt;&amp;lt;ep-url&amp;gt;&lt;/code&gt;，我们需要扩展 &lt;code&gt;marked&lt;/code&gt; 解析器。这是整个组件化系统的基石。&lt;/p&gt;
&lt;p&gt;&amp;lt;details open&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;summary&amp;gt;&amp;lt;strong&amp;gt;build-marked-extension.js&amp;lt;/strong&amp;gt;&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const cheerio = require(&quot;cheerio&quot;);

/**
 * 创建 marked 自定义扩展，实现自定义 block token
 * @param {string} name token 名字
 * @param {string} tagName 标签名字
 * @param {Function} renderer 渲染器
 * @returns
 */
function buildCustomBlockTokenExtension(name, tagName, { renderer }) {
  return {
    name,

    level: &quot;block&quot;,

    tokenizer(src) {
      const rule = new RegExp(
        `^&amp;lt;ep-${tagName}\\b[^&amp;gt;]*&amp;gt;\\n([\\s\\S]*?)\\n&amp;lt;\\/ep-${tagName}&amp;gt;`
      );

      const match = rule.exec(src);

      if (match) {
        const $ = cheerio.load(`&amp;lt;body&amp;gt;${match[0]}&amp;lt;/body&amp;gt;`);

        const attrs = getAllAttributes($(`body &amp;gt; ep-${tagName}`).get(0));

        const token = {
          type: name,
          raw: match[0],
          text: match[1].trim(),
          tokens: [],
          attrs,
        };

        this.lexer.blockTokens(token.text, token.tokens);

        return token;
      }

      return undefined;
    },

    renderer,
  };
}

/**
 * 创建 marked 自定义扩展，实现自定义 inline token
 * @param {string} name token 名字
 * @param {string} tagName 标签名字
 * @param {Function} renderer 渲染器
 * @returns
 */
function buildCustomInlineTokenExtension(name, tagName, { renderer }) {
  return {
    name,

    level: &quot;inline&quot;,

    start(src) {
      return src.match(new RegExp(`&amp;lt;ep-${tagName}&amp;gt;`))?.index;
    },

    tokenizer(src) {
      const rule = new RegExp(
        `^&amp;lt;ep-${tagName}\\b[^&amp;gt;]*&amp;gt;((?:(?!&amp;lt;\\/ep-${tagName}&amp;gt;)[\\s\\S])*?)&amp;lt;\\/ep-${tagName}&amp;gt;`
      );

      const match = rule.exec(src);

      if (match) {
        const $ = cheerio.load(`&amp;lt;body&amp;gt;${match[0]}&amp;lt;/body&amp;gt;`);

        const attrs = getAllAttributes($(`body &amp;gt; ep-${tagName}`).get(0));

        return {
          type: name,
          raw: match[0],
          text: match[1].trim(),
          tokens: this.lexer.inlineTokens(match[1].trim()),
          attrs,
        };
      }

      return undefined;
    },

    renderer,
  };
}

/**
 * 使用自定义标签包裹
 * @param {string} tagName
 * @param {string} content
 * @param {object} attrs
 * @returns
 */
function useCustomTag(tagName, content, attrs = {}) {
  const contentText = content ? `\n${content}\n` : &quot;&quot;;

  if (Object.keys(attrs).length) {
    const attrsText = Object.entries(attrs)
      .map(([k, v]) =&amp;gt; `${k}=&quot;${v}&quot;`)
      .join(&quot; &quot;);

    return `&amp;lt;ep-${tagName} ${attrsText}&amp;gt;${contentText}&amp;lt;/ep-${tagName}&amp;gt;`;
  }

  return `&amp;lt;ep-${tagName}&amp;gt;${contentText}&amp;lt;/ep-${tagName}&amp;gt;`;
}

const getAllAttributes = function (node) {
  const attributes =
    node.attributes ||
    Object.keys(node.attribs).map(name =&amp;gt; ({
      name,
      value: node.attribs[name],
    }));

  return attributes.reduce((acc, cur) =&amp;gt; {
    return {
      [cur.name]: cur.value,
      ...acc,
    };
  }, {});
};

module.exports = {
  buildCustomBlockTokenExtension,
  buildCustomInlineTokenExtension,
  useCustomTag,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;h3&gt;实现 TOC 目录组件&lt;/h3&gt;
&lt;p&gt;利用上面的构建器，我们可以快速定义一个目录组件的渲染逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 注册 TOC 扩展
const { marked } = require(&quot;marked&quot;);
const { buildCustomBlockTokenExtension } = require(&quot;./build-marked-extension&quot;);

const tocExtension = buildCustomBlockTokenExtension(&quot;toc&quot;, &quot;toc&quot;, {
  renderer(token) {
    // token.text 内容示例：&quot;🧩:: 第一节标题\n🔍:: 第二节标题&quot;
    const items = token.text.split(&quot;\n&quot;).filter(Boolean);

    const html = items
      .map(line =&amp;gt; {
        const [emoji, text] = line.split(&quot;::&quot;);
        return `&amp;lt;div class=&quot;toc-item&quot;&amp;gt;&amp;lt;span&amp;gt;${emoji}&amp;lt;/span&amp;gt;&amp;lt;a&amp;gt;${text}&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;`;
      })
      .join(&quot;&quot;);

    return `&amp;lt;section class=&quot;toc-container&quot;&amp;gt;${html}&amp;lt;/section&amp;gt;`;
  },
});

// 加载扩展
marked.use({ extensions: [tocExtension] });
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;多渠道发布系统 (Send2CMS)&lt;/h2&gt;
&lt;p&gt;这是最复杂的模块：将 Pad 的 AText 数据转换为“增强版 Markdown”，再渲染为 HTML。&lt;/p&gt;
&lt;h3&gt;AText 转增强 Markdown&lt;/h3&gt;
&lt;p&gt;我们需要编写转换器，遍历 AText 的 &lt;code&gt;attribs&lt;/code&gt;，将 &lt;code&gt;color&lt;/code&gt; 属性还原为 &lt;code&gt;&amp;lt;ep-color&amp;gt;&lt;/code&gt; 标签。&lt;/p&gt;
&lt;p&gt;&amp;lt;details&amp;gt;&lt;/p&gt;
&lt;p&gt;&amp;lt;summary&amp;gt;&amp;lt;strong&amp;gt;get-pad-markdown-document.js (点击展开)&amp;lt;/strong&amp;gt;&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const Changeset = require(&quot;ep_etherpad-lite/static/js/Changeset&quot;);
const padManager = require(&quot;ep_etherpad-lite/node/db/PadManager&quot;);

const { CUSTOM_TAGS } = require(&quot;../config&quot;);

const { correctLink } = require(&quot;./index&quot;);

const getCloseableTags = apool =&amp;gt; {
  const normalTags = [&quot;**&quot;, &quot;*&quot;, [&quot;&amp;lt;u&amp;gt;&quot;, &quot;&amp;lt;/u&amp;gt;&quot;], &quot;~~&quot;];
  const normalProps = [&quot;bold&quot;, &quot;italic&quot;, &quot;underline&quot;, &quot;strikethrough&quot;];

  const customAttrs = [
    CUSTOM_TAGS.COLOR,
    CUSTOM_TAGS.HIGHLIGHT,
    CUSTOM_TAGS.FONT_SIZE,
    CUSTOM_TAGS.URL,
    CUSTOM_TAGS.IMAGE_CAPTION,
  ];
  const customProps = [];

  apool.eachAttrib((k, v) =&amp;gt; {
    if (customAttrs.includes(k)) {
      if (v !== &quot;false&quot;) {
        customProps.push([k, v]);
      }
    }
  });

  const props = [...normalProps.map(p =&amp;gt; [p, true]), ...customProps];
  const tags = [
    ...normalTags.map(tag =&amp;gt; {
      const tags = Array.isArray(tag) ? tag : [tag, tag];

      const [open, close] = tags;

      return {
        open,
        close,
      };
    }),
    ...customProps.map(([k, v]) =&amp;gt; ({
      open: `&amp;lt;ep-${k} ${k}=&quot;${v}&quot;&amp;gt;`,
      close: `&amp;lt;/ep-${k}&amp;gt;`,
    })),
  ];
  const anumMap = {};

  props.forEach(([propName, propValue], i) =&amp;gt; {
    const propTrueNum = apool.putAttrib([propName, propValue], true);

    if (propTrueNum &amp;gt;= 0) {
      anumMap[propTrueNum] = i;
    }
  });

  return { props, tags, anumMap };
};

const getMarkdownFromAtext = (pad, atext) =&amp;gt; {
  const apool = pad.apool();
  const textLines = atext.text.slice(0, -1).split(&quot;\n&quot;);
  const attribLines = Changeset.splitAttributionLines(
    atext.attribs,
    atext.text
  );
  const { tags, props, anumMap } = getCloseableTags(apool);

  props.forEach((propName, i) =&amp;gt; {
    const propTrueNum = apool.putAttrib([propName, true], true);

    if (propTrueNum &amp;gt;= 0) {
      anumMap[propTrueNum] = i;
    }
  });

  const headingtags = [
    &quot;# &quot;,
    &quot;## &quot;,
    &quot;### &quot;,
    &quot;#### &quot;,
    &quot;##### &quot;,
    &quot;###### &quot;,
    &quot;    &quot;,
  ];
  const headingprops = [
    [&quot;heading&quot;, &quot;h1&quot;],
    [&quot;heading&quot;, &quot;h2&quot;],
    [&quot;heading&quot;, &quot;h3&quot;],
    [&quot;heading&quot;, &quot;h4&quot;],
    [&quot;heading&quot;, &quot;h5&quot;],
    [&quot;heading&quot;, &quot;h6&quot;],
    [&quot;heading&quot;, &quot;code&quot;],
  ];
  const headinganumMap = {};

  headingprops.forEach((prop, i) =&amp;gt; {
    let name;
    let value;
    if (typeof prop === &quot;object&quot;) {
      [name, value] = prop;
    } else {
      name = prop;
      value = true;
    }
    const propTrueNum = apool.putAttrib([name, value], true);
    if (propTrueNum &amp;gt;= 0) {
      headinganumMap[propTrueNum] = i;
    }
  });

  const getLineMarkdown = (text, attribs) =&amp;gt; {
    const propVals = [false, false, false];
    const ENTER = 1;
    const STAY = 2;
    const LEAVE = 0;

    // Use order of tags (b/i/u) as order of nesting, for simplicity
    // and decent nesting.  For example,
    // &amp;lt;b&amp;gt;Just bold&amp;lt;b&amp;gt; &amp;lt;b&amp;gt;&amp;lt;i&amp;gt;Bold and italics&amp;lt;/i&amp;gt;&amp;lt;/b&amp;gt; &amp;lt;i&amp;gt;Just italics&amp;lt;/i&amp;gt;
    // becomes
    // &amp;lt;b&amp;gt;Just bold &amp;lt;i&amp;gt;Bold and italics&amp;lt;/i&amp;gt;&amp;lt;/b&amp;gt; &amp;lt;i&amp;gt;Just italics&amp;lt;/i&amp;gt;
    const taker = Changeset.stringIterator(text);
    let assem = Changeset.stringAssembler();

    const openTags = [];
    const emitOpenTag = i =&amp;gt; {
      openTags.unshift(i);
      assem.append(tags[i].open);
    };

    const emitCloseTag = i =&amp;gt; {
      openTags.shift();
      assem.append(tags[i].close);
    };

    const orderdCloseTags = tags2close =&amp;gt; {
      for (let i = 0; i &amp;lt; openTags.length; i++) {
        for (let j = 0; j &amp;lt; tags2close.length; j++) {
          if (tags2close[j] === openTags[i]) {
            emitCloseTag(tags2close[j]);
            i--;
            break;
          }
        }
      }
    };

    // start heading check
    let heading = false;
    let deletedAsterisk = false; // we need to delete * from the beginning of the heading line
    const iter2 = Changeset.opIterator(Changeset.subattribution(attribs, 0, 1));
    if (iter2.hasNext()) {
      const o2 = iter2.next();

      // iterate through attributes
      Changeset.eachAttribNumber(o2.attribs, a =&amp;gt; {
        if (a in headinganumMap) {
          const i = headinganumMap[a]; // i = 0 =&amp;gt; bold, etc.
          heading = headingtags[i];
        }
      });
    }

    if (heading) {
      assem.append(heading);
    }

    const urls = _findURLs(text);

    let idx = 0;

    const processNextChars = numChars =&amp;gt; {
      if (numChars &amp;lt;= 0) {
        return;
      }

      const iter = Changeset.opIterator(
        Changeset.subattribution(attribs, idx, idx + numChars)
      );
      idx += numChars;

      while (iter.hasNext()) {
        const o = iter.next();
        let propChanged = false;
        Changeset.eachAttribNumber(o.attribs, a =&amp;gt; {
          if (a in anumMap) {
            const i = anumMap[a]; // i = 0 =&amp;gt; bold, etc.
            if (!propVals[i]) {
              propVals[i] = ENTER;
              propChanged = true;
            } else {
              propVals[i] = STAY;
            }
          }
        });
        for (let i = 0; i &amp;lt; propVals.length; i++) {
          if (propVals[i] === true) {
            propVals[i] = LEAVE;
            propChanged = true;
          } else if (propVals[i] === STAY) {
            propVals[i] = true; // set it back
          }
        }

        // now each member of propVal is in {false,LEAVE,ENTER,true}
        // according to what happens at start of span
        if (propChanged) {
          // leaving bold (e.g.) also leaves italics, etc.
          let left = false;
          for (let i = 0; i &amp;lt; propVals.length; i++) {
            const v = propVals[i];
            if (!left) {
              if (v === LEAVE) {
                left = true;
              }
            } else if (v === true) {
              propVals[i] = STAY; // tag will be closed and re-opened
            }
          }

          const tags2close = [];

          for (let i = propVals.length - 1; i &amp;gt;= 0; i--) {
            if (propVals[i] === LEAVE) {
              // emitCloseTag(i);
              tags2close.push(i);
              propVals[i] = false;
            } else if (propVals[i] === STAY) {
              // emitCloseTag(i);
              tags2close.push(i);
            }
          }

          orderdCloseTags(tags2close);

          for (let i = 0; i &amp;lt; propVals.length; i++) {
            if (propVals[i] === ENTER || propVals[i] === STAY) {
              emitOpenTag(i);
              propVals[i] = true;
            }
          }
          // propVals is now all {true,false} again
        } // end if (propChanged)
        let { chars } = o;
        if (o.lines) {
          chars--; // exclude newline at end of line, if present
        }

        let s = taker.take(chars);

        // removes the characters with the code 12. Don&apos;t know where they come
        // from but they break the abiword parser and are completly useless
        s = s.replace(String.fromCharCode(12), &quot;&quot;);

        // delete * if this line is a heading
        if (heading &amp;amp;&amp;amp; !deletedAsterisk) {
          s = s.substring(1);
          deletedAsterisk = true;
        }

        assem.append(s);
      } // end iteration over spans in line

      const tags2close = [];
      for (let i = propVals.length - 1; i &amp;gt;= 0; i--) {
        if (propVals[i]) {
          tags2close.push(i);
          propVals[i] = false;
        }
      }

      orderdCloseTags(tags2close);
    }; // end processNextChars

    if (urls) {
      urls.forEach(urlData =&amp;gt; {
        const startIndex = urlData[0];
        const url = urlData[1];
        const urlLength = url.length;
        processNextChars(startIndex - idx);
        assem.append(`[${url}](`);
        processNextChars(urlLength);
        assem.append(&quot;)&quot;);
      });
    }

    processNextChars(text.length - idx);

    // replace &amp;amp;, _
    assem = assem.toString();
    assem = assem.replace(/&amp;amp;/g, &quot;\\&amp;amp;&quot;);
    // this breaks Markdown math mode: $\sum_i^j$ becomes $\sum\_i^j$
    assem = assem.replace(/_/g, &quot;\\_&quot;);

    return assem;
  };
  // end getLineMarkdown
  const pieces = [];

  // Need to deal with constraints imposed on HTML lists; can
  // only gain one level of nesting at once, can&apos;t change type
  // mid-list, etc.
  // People might use weird indenting, e.g. skip a level,
  // so we want to do something reasonable there.  We also
  // want to deal gracefully with blank lines.
  // =&amp;gt; keeps track of the parents level of indentation
  const lists = []; // e.g. [[1,&apos;bullet&apos;], [3,&apos;bullet&apos;], ...]
  for (let i = 0; i &amp;lt; textLines.length; i++) {
    const line = _analyzeLine(textLines[i], attribLines[i], apool);
    let lineContent = getLineMarkdown(line.text, line.aline);

    // If we are inside a list
    if (line.listLevel) {
      // do list stuff
      let whichList = -1; // index into lists or -1
      if (line.listLevel) {
        whichList = lists.length;
        for (let j = lists.length - 1; j &amp;gt;= 0; j--) {
          if (line.listLevel &amp;lt;= lists[j][0]) {
            whichList = j;
          }
        }
      }

      // means we are on a deeper level of indentation than the
      // previous line
      if (whichList &amp;gt;= lists.length) {
        lists.push([line.listLevel, line.listTypeName]);
      }

      if (line.listTypeName === &quot;number&quot;) {
        pieces.push(
          `\n${new Array(line.listLevel * 4).join(&quot; &quot;)}1. `,
          lineContent || &quot;\n&quot;
        ); // problem here
      } else {
        pieces.push(
          `\n${new Array(line.listLevel * 4).join(&quot; &quot;)}* `,
          lineContent || &quot;\n&quot;
        ); // problem here
      }
    } else {
      // outside any list
      const context = {
        line,
        lineContent,
        apool,
        attribLine: attribLines[i],
        text: textLines[i],
      };

      lineContent = getLineMarkdownForExport(context);
      pieces.push(&quot;\n&quot;, lineContent, &quot;\n&quot;);
    }
  }

  return pieces.join(&quot;&quot;);
};

// 参考 getLineHTMLForExport 的实现，返回自定义的 Markdown 内容
function getLineMarkdownForExport(context) {
  const img = analyzeLineForTag(context.attribLine, context.apool, &quot;img&quot;);
  const customImg = analyzeLineForTag(
    context.attribLine,
    context.apool,
    &quot;customImg&quot;
  );

  if (img) {
    return `![](${img})`;
  }

  if (customImg) {
    return `![](${customImg})`;
  }

  return context.lineContent;
}

function analyzeLineForTag(alineAttrs, apool, tag) {
  let result = null;

  if (alineAttrs) {
    const opIter = Changeset.opIterator(alineAttrs);
    if (opIter.hasNext()) {
      const op = opIter.next();
      result = Changeset.opAttributeValue(op, tag, apool);
    }
  }

  return result;
}

const _analyzeLine = (text, aline, apool) =&amp;gt; {
  const line = {};

  // identify list
  let lineMarker = 0;
  line.listLevel = 0;
  if (aline) {
    const opIter = Changeset.opIterator(aline);
    if (opIter.hasNext()) {
      let listType = Changeset.opAttributeValue(opIter.next(), &quot;list&quot;, apool);
      if (listType) {
        lineMarker = 1;
        listType = /([a-z]+)([12345678])/.exec(listType);
        if (listType) {
          /* eslint-disable-next-line prefer-destructuring */
          line.listTypeName = listType[1];
          line.listLevel = Number(listType[2]);
        }
      }
    }
  }
  if (lineMarker) {
    line.text = text.substring(1);
    line.aline = Changeset.subattribution(aline, 1);
  } else {
    line.text = text;
    line.aline = aline;
  }

  return line;
};

const getPadMarkdown = async (pad, revNum) =&amp;gt; {
  const atext =
    revNum == null ? pad.atext : await pad.getInternalRevisionAText(revNum);

  return getMarkdownFromAtext(pad, atext);
};

const formatMarkdown = markdown =&amp;gt; {
  return markdown
    .split(&quot;\n&quot;)
    .map(e =&amp;gt; {
      /**
       * 格式化 list 缩进
       */
      if (e.trim().startsWith(&quot;- &quot;)) {
        const text = e.trim();

        if (text.includes(&quot;([&quot;)) {
          return correctLink(text);
        }

        return text;
      }

      if (e.trim().startsWith(&quot;* -&quot;)) {
        return e.trim().replace(&quot;* -&quot;, &quot;-&quot;);
      }

      // 解决链接嵌套问题
      if (e.startsWith(&quot;### &quot;)) {
        return `### ${correctLink(e.split(&quot;### &quot;).pop())}`;
      }

      if (e.includes(&quot;([&quot;)) {
        return correctLink(e);
      }

      return e;
    })
    .join(&quot;\n&quot;);
};

module.exports = async function getPadMarkdownDocument(padId, revNum) {
  let res = await getPadMarkdown(await padManager.getPad(padId), revNum);

  res = formatMarkdown(res);

  return res;
};

// copied from ACE
const _REGEX_WORDCHAR = new RegExp(
  [
    &quot;[&quot;,
    &quot;\u0030-\u0039&quot;,
    &quot;\u0041-\u005A&quot;,
    &quot;\u0061-\u007A&quot;,
    &quot;\u00C0-\u00D6&quot;,
    &quot;\u00D8-\u00F6&quot;,
    &quot;\u00F8-\u00FF&quot;,
    &quot;\u0100-\u1FFF&quot;,
    &quot;\u3040-\u9FFF&quot;,
    &quot;\uF900-\uFDFF&quot;,
    &quot;\uFE70-\uFEFE&quot;,
    &quot;\uFF10-\uFF19&quot;,
    &quot;\uFF21-\uFF3A&quot;,
    &quot;\uFF41-\uFF5A&quot;,
    &quot;\uFF66-\uFFDC&quot;,
    &quot;]&quot;,
  ].join(&quot;&quot;)
);
const _REGEX_URLCHAR = new RegExp(
  `([-:@a-zA-Z0-9_.,~%+/\\?=&amp;amp;#;()$]|${_REGEX_WORDCHAR.source})`
);
const _REGEX_URL = new RegExp(
  &quot;(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt)://|mailto:)&quot; +
    `${_REGEX_URLCHAR.source}*(?![:.,;])${_REGEX_URLCHAR.source}`,
  &quot;g&quot;
);
// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...]
const _findURLs = text =&amp;gt; {
  _REGEX_URL.lastIndex = 0;
  let urls = null;
  let execResult;
  // eslint-disable-next-line no-cond-assign
  while ((execResult = _REGEX_URL.exec(text))) {
    urls = urls || [];
    const startIndex = execResult.index;
    const url = execResult[0];
    urls.push([startIndex, url]);
  }
  return urls;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;h3&gt;渲染隔离与污染治理&lt;/h3&gt;
&lt;p&gt;在多渠道发布时，&lt;code&gt;marked.use()&lt;/code&gt; 会污染全局实例。如果渠道 A 需要 iframe 视频，渠道 B 只需要链接，必须进行&lt;strong&gt;扩展隔离&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 每次渲染前重置扩展
const { marked } = require(&quot;marked&quot;);

function renderForChannel(markdown, channelExtensions) {
  // 1. 获取默认扩展
  const defaults = marked.defaults.extensions || {
    renderers: {},
    childTokens: {},
  };

  // 2. 动态合并当前渠道需要的扩展
  const newExtensions = { ...defaults, ...channelExtensions };

  // 3. 强制重置 marked 配置 (HACK)
  marked.setOptions({ extensions: newExtensions });

  return marked.parse(markdown);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;避坑与经验&lt;/h2&gt;
&lt;h3&gt;链接嵌套修复与清洗&lt;/h3&gt;
&lt;p&gt;协作编辑时，用户经常造出 &lt;code&gt;[text]([inner](url))&lt;/code&gt; 这种非法 Markdown，导致解析崩溃。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// utils/index.js

/**
 * 修复嵌套链接：[text]([inner](url)) -&amp;gt; [text](url)
 */
function correctLink(markdownText) {
  const pattern = /\[(.+)\]\(\[(.+)\]\((.+)\)\)/g;
  return markdownText.replace(pattern, &quot;[$1]($3)&quot;);
}

/**
 * HTML 清洗：移除多余的 P 标签
 */
const removePTag = html =&amp;gt; {
  return html.replace(/&amp;lt;p&amp;gt;/g, &quot;&quot;).replace(/&amp;lt;\/p&amp;gt;/g, &quot;&quot;);
};

/**
 * 链接还原：将 Markdown 链接转为纯文本 (用于生成纯文本目录)
 */
function convertLinksToText(markdownText) {
  return markdownText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, &quot;$1&quot;);
}

module.exports = { correctLink, removePTag, convertLinksToText };
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Iframe 穿透 (jQuery)&lt;/h3&gt;
&lt;p&gt;在 Client 端开发时，切记 Ace 运行在嵌套 iframe 中。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 获取 inner editor 的 body
const $innerBody = $(&apos;iframe[name=&quot;ace_outer&quot;]&apos;)
  .contents()
  .find(&apos;iframe[name=&quot;ace_inner&quot;]&apos;)
  .contents()
  .find(&quot;body&quot;);

// 绑定事件必须穿透
$innerBody.on(&quot;click&quot;, &quot;a&quot;, function (e) {
  // ...
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;服务端路由：大文件上传限制&lt;/h3&gt;
&lt;p&gt;如果你在插件中处理图片上传，Express 默认的限制会导致 413 错误。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 在 hook &apos;expressCreateServer&apos; 中配置
exports.expressCreateServer = (hookName, args, cb) =&amp;gt; {
  const app = args.app;
  // 调大限制到 50mb
  app.use(express.json({ limit: &quot;50mb&quot; }));
  app.use(express.urlencoded({ limit: &quot;50mb&quot;, extended: true }));
  cb();
};
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>多 Mac 设备配置同步方案</title><link>https://4ark.me/posts/2025-11-08-multi-mac-device-mackup/</link><guid isPermaLink="true">https://4ark.me/posts/2025-11-08-multi-mac-device-mackup/</guid><description>用 mackup 实现多台 Mac 设备配置与软件环境的一键快速同步。</description><pubDate>Wed, 10 Dec 2025 01:46:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;过去几年，我的主力机一直是那台 14 英寸的 M1 Pro，虽然也有 dotfiles 仓库，通过手工软链来同步，但充其量只是为了版本控制。&lt;/p&gt;
&lt;p&gt;直到我入手了 Mac mini M4，才有了要同步两台设备配置的需求。因为我已经不止一次：在 A 机上装了某个命令行工具，到了 B 机又得重新安装、重新配置……折腾几次后，还是得找一种方案。&lt;/p&gt;
&lt;p&gt;我的目标很简单：只要在任意一台机器上安装新软件或修改配置，其他设备可以快速做到 1:1 同步。&lt;/p&gt;
&lt;p&gt;核心就两条命令：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;离开当前机器前，运行 macup —— 将所有改动备份到仓库&lt;/li&gt;
&lt;li&gt;到另一台机器前，执行 macdown —— 从仓库里还原&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;以下全是细节。&lt;/p&gt;
&lt;h2&gt;为什么不用这些方案&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;chezmoi / yadm：有点重&lt;/li&gt;
&lt;li&gt;Nix / Home Manager：更重&lt;/li&gt;
&lt;li&gt;Mackup + iCloud/Dropbox：确实方便，但更希望放在 Git 里&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;目前方案&lt;/h2&gt;
&lt;p&gt;最终核心就三板斧：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Mackup + file_system：所有应用配置都放在 dotfiles 仓库&lt;/li&gt;
&lt;li&gt;Brew 的 formula 和 cask 列表，并且有前缀 + 表示需要同步安装&lt;/li&gt;
&lt;li&gt;两个脚本：macup 负责备份并更新列表，macdown 负责还原&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;仓库结构示例如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dotfiles/
├── .mackup.cfg
├── mackup/                     # mackup 导出的所有配置
├── mackup/brew-formulae.txt
├── mackup/brew-casks.txt
├── bin/macup → ../mackup-backup.sh
├── bin/macdown → ../mackup-restore.sh
├── bin/xxx                     # 任何可执行文件，会自动软链到 ~/.bin
├── mackup-backup.sh
├── mackup-restore.sh
└── init.sh                     # 新机器第一步运行，自动软链 .mackup.cfg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;.mackup.cfg 示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[storage]
engine    = file_system
path      = /Users/4ark/projects/dotfiles
directory = mackup
[applications_to_sync]
Bash
Charles
Cursor
claude-code
dig
git-hooks
homebrew
Htop
Itsycal
custom-kitty
nvm
PicGo
Pnpm
ripgrep
SourceTree
yazi
Zsh
Mercurial
p10k
vim
neovim
ssh
starship
[applications_to_ignore]
adium
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;核心文件说明&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;mackup/：mackup 导出的配置/偏好，直接放在仓库里版本化&lt;/li&gt;
&lt;li&gt;mackup/brew-formulae.txt、mackup/brew-casks.txt：Brew 安装列表，使用 +包名 表示需要安装，注释以 # 开头；脚本会去重、排序并保留注释&lt;/li&gt;
&lt;li&gt;mackup-backup.sh / bin/macup：备份脚本，负责合并列表、执行 mackup backup 并同步 bin/&lt;/li&gt;
&lt;li&gt;mackup-restore.sh / bin/macdown：还原脚本，负责差异预览、执行 mackup restore 并按列表安装软件&lt;/li&gt;
&lt;li&gt;init.sh：新机器上第一步运行，为 ~/.mackup.cfg 创建软链&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;备份流程：macup&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;收集当前机器的 formula/cask，与列表合并并展示差异&lt;/li&gt;
&lt;li&gt;确认后写回 brew-formulae.txt 和 brew-casks.txt&lt;/li&gt;
&lt;li&gt;运行 mackup backup --force，将所有配置导出到 mackup/&lt;/li&gt;
&lt;li&gt;将仓库的 bin/ 软链到 ~/.bin&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;恢复流程：macdown&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;读取 mackup/brew-*.txt，与本机已安装列表对比，并展示「待安装／可移除」项&lt;/li&gt;
&lt;li&gt;确认后执行 mackup restore --force，将 mackup/ 中的配置恢复到对应位置&lt;/li&gt;
&lt;li&gt;按列表逐个安装缺失的 formula/cask（已安装的会自动跳过）&lt;/li&gt;
&lt;li&gt;同步 bin/ 到 ~/.bin（与 macup 流程相同）&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>OpenSpec 使用心得</title><link>https://4ark.me/posts/2025-11-04-openspec/</link><guid isPermaLink="true">https://4ark.me/posts/2025-11-04-openspec/</guid><description>一文聊聊 OpenSpec 的核心价值、使用流程及实战经验。</description><pubDate>Tue, 04 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;一、引言&lt;/h2&gt;
&lt;p&gt;如果在 2025 年，你还没有在工作中借助 AI，要么你的水平已经超越 AI，要么你就是被 AI 代替的部分。大多数人都不是前者，也不愿成为后者。如何高效、可靠地利用 AI，是每位开发者的必修课。&lt;/p&gt;
&lt;p&gt;我个人的探索大致经历了以下几个阶段，从最初的简单补全，到如今的规范驱动开发——OpenSpec。&lt;/p&gt;
&lt;h2&gt;二、AI 工具演进阶段&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;洪荒时代：AI 仅作“补全”
• 编辑器里集成 Copilot 插件，或者 ChatGPT 网页窗口复制粘贴，AI 只能做最基础的代码补全，效率提升有限。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;集成时代：对话式编辑
• Cursor 等工具将聊天窗口直接搬进 IDE，上下文无缝传递，减少复制粘贴的步骤。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;增强时代：MCP 协议崛起
• Model Context Protocol 赋予 AI Agent 文件读写、命令执行、API 调用能力，自动化水平大幅跃升。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;智能时代：自主驱动
• AI Agent 能理解复杂任务，分解步骤，自主调用工具链完成工作，不再是简单的问答助手，而是智能协作伙伴。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;三、现有困境与解决方案&lt;/h2&gt;
&lt;p&gt;尽管 AI 功能越来越强，但在团队协作中，仍面临几大痛点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;上下文串台：长对话中不同任务相互干扰&lt;/li&gt;
&lt;li&gt;信息丢失：超出上下文窗口后，需反复重新说明&lt;/li&gt;
&lt;li&gt;难以复用：项目约定无法在新会话中继承&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;过去，我常让 AI Agent 先输出思路再动手，但每次改动都像黑盒测试，改错了只能重来。&lt;/p&gt;
&lt;p&gt;这也是大家遇到的问题，所以最近出现了一系列工具：spec-kit、OpenSpec 等。&lt;/p&gt;
&lt;h3&gt;OpenSpec 核心思路&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;“每次改动都是一个提案”
• 在动手之前，先形成结构化的 &lt;code&gt;proposal.md&lt;/code&gt;，明确 Why / What / How / Impact，我们 Review 通过后再执行。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;规范驱动、约定可持久化
• 所有决策、设计、验收标准都以文件形式保存在仓库里，新工具或新同事都能快速“秒懂”项目规范。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;通过将项目规范、架构决策、功能需求以结构化文档记录，OpenSpec 保证了上下文一致性、变更可追溯，也让 AI Agent 在任何时刻都能准确执行。&lt;/p&gt;
&lt;h2&gt;四、角色转变&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;之前：在微观层面指挥 AI Agent “写这段代码”“改那个函数”&lt;/li&gt;
&lt;li&gt;现在：从宏观角度当“产品经理”或“团队 Leader”
&lt;ol&gt;
&lt;li&gt;我来讲需求&lt;/li&gt;
&lt;li&gt;AI Agent 起草提案&lt;/li&gt;
&lt;li&gt;我 Review 并反馈&lt;/li&gt;
&lt;li&gt;AI Agent 修改提案&lt;/li&gt;
&lt;li&gt;我确认后，AI Agent 执行代码落地&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样一来，我们从指令层的操作者，变成了规范层的把关者，放权给 AI Agent 干更多“脏活累活”。&lt;/p&gt;
&lt;h2&gt;五、OpenSpec 使用案例&lt;/h2&gt;
&lt;p&gt;下面以“撰写本篇文章”为例，演示 OpenSpec 的完整流程。&lt;/p&gt;
&lt;h3&gt;1. 安装 OpenSpec&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 检查 Node.js 版本（需要 &amp;gt;= 20.19.0）
node --version
# v24.11.0

npm install -g @fission-ai/openspec@latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 初始化 OpenSpec&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd my-project
openspec init
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;选择支持的 AI 工具（如 Claude Code、Cursor 等）&lt;/li&gt;
&lt;li&gt;自动生成 &lt;code&gt;openspec/project.md&lt;/code&gt;、&lt;code&gt;openspec/AGENTS.md&lt;/code&gt;、&lt;code&gt;openspec/specs/&lt;/code&gt;、&lt;code&gt;openspec/changes/&lt;/code&gt; 等目录结构&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;验证初始化成功，只需要执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openspec list
# No active changes found.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后让 AI Agent 帮你完善项目背景：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Populate your project context:
&quot;Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 创建提案&lt;/h3&gt;
&lt;p&gt;向 AI Agent 提需求：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“在 &lt;code&gt;src/content/blog&lt;/code&gt; 下新建《OpenSpec 使用心得》文章，包含：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;引言：为何要拥抱 AI&lt;/li&gt;
&lt;li&gt;AI 演进阶段&lt;/li&gt;
&lt;li&gt;现有困境与解决方案&lt;/li&gt;
&lt;li&gt;OpenSpec 流程&lt;/li&gt;
&lt;li&gt;个人实践心得”&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;AI Agent 自动生成 &lt;code&gt;openspec/changes/complete-openspec-article/proposal.md&lt;/code&gt;、&lt;code&gt;tasks.md&lt;/code&gt;、相应 &lt;code&gt;specs/&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;proposal.md&lt;/code&gt;&lt;/strong&gt; 包含了提案的核心信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## Why

现有的 `src/content/blog/2025-11-04-openspec.md` 文章内容不完整，
需要补充完整的内容以完成一篇关于 OpenSpec 使用心得的博客文章。

## What Changes

- 完成博客文章 `src/content/blog/2025-11-04-openspec.md` 的写作
- **保留现有内容**：不删除已有的引言、演进阶段和困境解决方案部分
- **文章要求**：
  - 确保内容准确性
  - AI 工具演进阶段需准确描述
  - 精简整篇文章篇幅...
  - 统一名词使用...

## Impact

- 受影响文件：`src/content/blog/2025-11-04-openspec.md`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;tasks.md&lt;/code&gt;&lt;/strong&gt; 列出了详细的实施任务清单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## 1. 内容创作

- [ ] 1.1 完成第一部分：引言：拥抱 AI 工具
- [ ] 1.2 完成第二部分：AI 工具的演进阶段
  - [ ] 1.2.1 以段落形式描述四个阶段...
- [ ] 1.3 完成第三部分：现有困境与解决方案
- [ ] 1.4 完成第四部分：OpenSpec 的特点与使用流程
  - [ ] 1.4.1 准确介绍 spec-kit 和 OpenSpec 工具...
  - [ ] 1.4.5 使用案例：完成这篇文章提案本身...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;specs/blog-content/spec.md&lt;/code&gt;&lt;/strong&gt; 定义了规范要求：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;## ADDED Requirements

### Requirement: OpenSpec 使用心得博客文章

博客系统 SHALL 包含一篇完整的关于 OpenSpec 使用心得的文章...

#### Scenario: 文章内容完整性

- **WHEN** 用户访问博客文章页面
- **THEN** 文章应包含所有五个主要部分...

#### Scenario: 文章精炼度

- **WHEN** 文章完成
- **THEN** 应遵循以下原则：
  - 去除所有冗余和重复内容...
  - 使用案例部分需要详细描述...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. 反复打磨&lt;/h3&gt;
&lt;p&gt;我在对话中提出细化或优化建议，AI Agent 即刻更新提案，直到内容满足需求，再进入“实施”阶段。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;更新提案，介绍完现有的困境，以及 spec-kit 和 openspec 这类工具的优势以后，就要开始一个使用案例，我就以本次我是如何使用 openspec 帮我完成这个提案本身，去写这篇文章。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;更新提案，文章里面有几个部分需要调整：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;AI 工具的演进阶段，是不是足够准确&lt;/li&gt;
&lt;li&gt;精简整篇文章，去除冗余和重复内容&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5. 实施与归档&lt;/h3&gt;
&lt;p&gt;跟 AI Agent 说：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;实施这个提案&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;它会自动帮你执行这个指令：&lt;code&gt;openspec apply complete-openspec-article&lt;/code&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;归档这个提案&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;对应这个指令：&lt;code&gt;openspec archive complete-openspec-article --yes&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;提案及规范文件被归档到 &lt;code&gt;openspec/changes/archive/2025-11-04-complete-openspec-article/&lt;/code&gt;，形成完整可追溯的记录。&lt;/p&gt;
&lt;h2&gt;六、个人实践心得&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;我只需记住如何初始化 OpenSpec，具体命令让 AI Agent 自动完成。&lt;/li&gt;
&lt;li&gt;项目规范文件是最宝贵的资产：新工具、新同事都能无缝衔接，消除了流程断层。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>uni-app 多端组件属性与样式透传行为一致性实践</title><link>https://4ark.me/posts/2025-10-28-uni-app-component-props-style-pass-through/</link><guid isPermaLink="true">https://4ark.me/posts/2025-10-28-uni-app-component-props-style-pass-through/</guid><description>探讨如何在 uni-app 多端开发中实现组件属性与样式透传的一致性，分享实践经验和解决方案，提高跨端开发效率。</description><pubDate>Tue, 28 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;现状分析&lt;/h2&gt;
&lt;p&gt;起因是一个以微信小程序为主开发的 uni-app 应用，在编译到 App 端后，出现了各种样式问题。&lt;/p&gt;
&lt;p&gt;为了更好地理解这个问题，让我们创建一个最小化的示例项目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm create uni@latest # 什么都不加
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个经典的嵌套组件，用于测试三个端组件样式表现：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;这里可以看到，每个组件都开启了 style scoped，此时三个端的表现是一致的，没有任何问题：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;然而，在实际开发中，我们经常会遇到这样一种情况：由于启用了 style scoped，往往会倾向于使用简单的 class 命名，比如大量使用 container 这样的通用类名。让我们看一个具体示例：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;在这种情况下，三个端的表现会出现明显的差异：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;这是 bug？先说结论，这是 Vue 的 &lt;a href=&quot;https://cn.vuejs.org/api/sfc-css-features#child-component-root-elements&quot;&gt;feature&lt;/a&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;使用 scoped 后，父组件的样式将不会渗透到子组件中。不过，子组件的根节点会同时被父组件的作用域样式和子组件的作用域样式影响。这样设计是为了让父组件可以从布局的角度出发，调整其子组件根元素的样式。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;可前面说了，我们是以微信小程序为主进行开发，看到这种情况还是会非常懵逼。&lt;/p&gt;
&lt;p&gt;所以，这就不得不先看一下 uni-app 在不同端是如何实现样式隔离的。&lt;/p&gt;
&lt;h2&gt;样式隔离&lt;/h2&gt;
&lt;p&gt;App 端的实现方式其实与我们熟悉的 Vue 浏览器端机制一样：它们都是通过为每个标签动态添加 [data-v-scopeId] 属性来实现样式的作用域隔离。&lt;/p&gt;
&lt;p&gt;而在微信小程序端，由于小程序的 CSS 不支持属性选择器，uni-app 采用了一种变通方案：为每个标签添加带有 [data-v-scopeId] 的 class 来实现隔离效果。&lt;/p&gt;
&lt;p&gt;下面这张图可以帮助我们直观地理解三个端的 HTML 结构差异：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;这里我们可以留意到，子组件的根元素会附带上父组件的 scopeId，所以父组件可以影响它的样式。&lt;/p&gt;
&lt;p&gt;而在微信小程序，因为多了一层 &lt;code&gt;&amp;lt;components/comp-child&amp;gt;&lt;/code&gt; 这样的东西，scopeId 并不在真正的组件根元素，所以它的表现会与其余两个端不一致。&lt;/p&gt;
&lt;h2&gt;解决方案&lt;/h2&gt;
&lt;p&gt;因为我们还是希望 App 端可以与微信小程序端的表现保持一致，此时有两条路可以走：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;不要这个 feature&lt;/li&gt;
&lt;li&gt;保留这个 feature&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;反正我们的目的只有一个：减少开发时的心智负担。&lt;/p&gt;
&lt;h3&gt;方案一&lt;/h3&gt;
&lt;p&gt;对于第一条路子，实现方式非常简单粗暴，既然 Vue 只针对单根组件会有这个 feature，那我们强制所有组件变成多根节点就好了，简单说就是实现一个 Vite 插件，利用 transform 钩子往 Vue 组件顶层插入一个 &lt;code&gt;&amp;lt;Fragment /&amp;gt;&lt;/code&gt; 元素，这样立马可以让 App 和 H5 的表现与微信小程序保持一致。&lt;/p&gt;
&lt;p&gt;然而这样会损失 Vue 特地带来的便利，正如官方文档所说，这个 feature 会无形中减少很多布局实现上的麻烦，因此最终还是决定让微信小程序能够对齐另外两端的实现。&lt;/p&gt;
&lt;h3&gt;方案二&lt;/h3&gt;
&lt;p&gt;根据前面的 HTML 结构图可以看到，微信小程序主要是因为多了一层节点(虚拟节点)，是不是只要把它干掉就好了？&lt;/p&gt;
&lt;p&gt;说干就干，在 uni-app &lt;a href=&quot;https://uniapp.dcloud.net.cn/tutorial/vue3-api.html#%E5%85%B6%E4%BB%96%E9%85%8D%E7%BD%AE&quot;&gt;文档&lt;/a&gt;中指出，只要在 Vue 组件中配置 &lt;code&gt;virtualHost: true&lt;/code&gt; 并且配合 &lt;code&gt;mergeVirtualHostAttributes&lt;/code&gt; 即可合并组件虚拟节点外层属性：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;然而，此时 HTML 结构中 scopeId 虽然已经放到组件的根节点上，但样式表现仍然没有发生变化，这是因为 uni-app 默认给微信小程序组件设置的样式隔离是 &lt;code&gt;apply-shared&lt;/code&gt;，还需要把 &lt;code&gt;styleIsolation&lt;/code&gt; 改成 &lt;code&gt;shared&lt;/code&gt; 才能使其影响其它组件。&lt;/p&gt;
&lt;p&gt;对应到本文例子就是要给 &lt;code&gt;comp-parent.vue&lt;/code&gt; 添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;defineOptions({
  options: {
    virtualHost: true,
    styleIsolation: &quot;shared&quot;, // [!code ++]
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;修改以后，微信小程序上的表现已经完全与其余两端一致：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;这个方案的好处在于，可以让微信小程序组件无限接近 Vue 组件的表现，除了样式透传，还包括可以属性透传，比如 id、class、style、v-show 等。&lt;/p&gt;
&lt;p&gt;我写了一个 Vite 插件自动注入这个 &lt;code&gt;virtualHost: true&lt;/code&gt; 配置，有兴趣可以看：https://github.com/gd4Ark/vite-plugin-uni-virtual-host&lt;/p&gt;
&lt;h2&gt;页面样式优先级&lt;/h2&gt;
&lt;p&gt;其实到这里三端表现已经基本一致，但在微信小程序还有一些细微的差别，主要问题是出在样式优先级。&lt;/p&gt;
&lt;p&gt;还是上面那个例子，在页面级组件去覆写子组件样式：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;由于微信小程序加载 css 顺序的问题，表现与其余两端不一致：&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;这问题看起来是无解的，只能手动识别这种情况，并添加 &lt;code&gt;!important&lt;/code&gt; 提高样式权重。&lt;/p&gt;
</content:encoded></item><item><title>uni-app 与原生小程序混合开发方案</title><link>https://4ark.me/posts/2025-10-15-uni-app-hybrid-native-miniprogram/</link><guid isPermaLink="true">https://4ark.me/posts/2025-10-15-uni-app-hybrid-native-miniprogram/</guid><description>探索并实践 uni-app 与原生小程序的混合开发方案，包括将原生页面集成到 uni-app 项目中，以及将 uni-app 项目作为分包集成到原生项目中两种方案的具体实现。</description><pubDate>Wed, 15 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;在过去两年里，我接手维护了多个原生语法开发的微信小程序项目。由于新项目均采用 uni-app 开发，这些原生项目无法复用在 uni-app 生态积累的工具库、业务组件和 Hooks 等基础设施。&lt;/p&gt;
&lt;p&gt;为了解决技术栈割裂的问题，探索并实践了多种混合开发方案，本文将分享相关的技术方案与实践经验。&lt;/p&gt;
&lt;h2&gt;项目现状分析&lt;/h2&gt;
&lt;p&gt;或许会好奇，为什么不直接选择用 uni-app 对项目进行全面重构呢？&lt;/p&gt;
&lt;p&gt;其实，除了精力有限，更重要的原因在于：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;线上存在众多活动页，这些页面相对独立且变动频率低，重构它们的收益并不高&lt;/li&gt;
&lt;li&gt;项目已有功能较为复杂，后续开发需求主要是新增页面，而不是对现有功能进行大规模改造&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;综上，采用混合开发的渐进式方案，无疑更加高效且具性价比。目前项目主要面临两类需求场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;项目主框架用 uni-app 重构，但需要继续复用原有的众多子页面（如各类活动页）&lt;/li&gt;
&lt;li&gt;老项目已趋于稳定，仅需在其基础上新增某些复杂且相对独立的模块&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;uni-app 官方文档提供了几种与原生小程序混合开发的&lt;a href=&quot;https://uniapp.dcloud.net.cn/hybrid.html#uni-app-%E5%92%8C%E5%8E%9F%E7%94%9F%E5%B0%8F%E7%A8%8B%E5%BA%8F%E6%B7%B7%E5%90%88%E5%BC%80%E5%8F%91%E9%97%AE%E9%A2%98&quot;&gt;技术方案&lt;/a&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;方式 1：把原生小程序转换为 uni-app 源码。有各种转换工具&lt;/li&gt;
&lt;li&gt;方式 2：把原生小程序的代码变成小程序组件，进而整合到 uni-app 项目下&lt;/li&gt;
&lt;li&gt;方式 3：原生开发的小程序仍保留，部分新功能使用 uni-app 开发&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;然而，这些方案无法直接满足目前的场景。鉴于涉及的项目较多，决定设计一套更具通用性和可扩展性的混合开发方案，以方便快速适配应用。&lt;/p&gt;
&lt;h2&gt;uni-app 项目复用原生小程序页面&lt;/h2&gt;
&lt;p&gt;这种方案核心思路是：将原生小程序页面搬到 uni-app 项目的构建产物中，并注册页面。&lt;/p&gt;
&lt;p&gt;为了提升开发效率，需要将这个过程自动化。所以，开发 Vite 插件来做这件事情最适合不过了。&lt;/p&gt;
&lt;p&gt;假设项目结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.
├── src/                # uni-app 项目主目录
│   ├── pages/          # uni-app 页面
│   ├── components/     # uni-app 组件
│   └── ...
├── miniprogram/        # 原生微信小程序代码目录
│   ├── components/     # 需要复用的原生组件
│   ├── pages/          # 需要复用的原生页面
│   └── ...
├── vite.config.ts      # Vite 配置文件
└── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该 Vite 插件需要实现以下核心功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;构建阶段：将 miniprogram 目录中的目标文件自动同步到 dist 构建目录&lt;/li&gt;
&lt;li&gt;资源管理：将原生项目的公共模块（如工具库、依赖包等）统一迁移至独立的 shared 命名空间，并自动更新相关模块的引用路径，避免与 uni-app 构建产物发生冲突&lt;/li&gt;
&lt;li&gt;配置更新：自动维护 app.json 配置文件，处理页面路由注册和全局组件声明&lt;/li&gt;
&lt;li&gt;开发体验：实现文件系统监听，当检测到 miniprogram 目录的文件变更时，自动触发增量构建&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;伪代码实现，大概流程就是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function uniWxCopyPlugin(options) {
  let publicBasePath = &apos;&apos; // Vite 的 base 配置
  let configPath = &apos;&apos;     // 最终输出目录
  let isDev = false      // 是否开发环境

  return {
    name: &apos;vite-plugin-uni-wx-copy&apos;,

    // Vite 配置解析完成后执行
    configResolved(config) {
      // 判断是否为微信小程序环境
      if (config.define[&apos;process.env.UNI_PLATFORM&apos;] !== &apos;&quot;mp-weixin&quot;&apos;) {
        return
      }

      publicBasePath = config.base
      isDev = config.mode === &apos;development&apos;
    },

    // 构建完成后执行
    writeBundle(options) {
      const p = options.dir // 构建输出目录

      // 如果没有输出目录或 base 配置，则不执行
      if (!p || !publicBasePath) {
        return
      }

      // 计算最终输出目录
      configPath = resolve(publicBasePath, p)

      // 1. 复制原生组件到共享目录
      copy(
        &apos;miniprogram/components&apos; -&amp;gt;
        configPath + &apos;/shared/components&apos;
      )

      // 2. 复制页面
      copy(
        &apos;miniprogram/pages/index&apos; -&amp;gt;
        configPath + &apos;/pages/index&apos;
      )

      // 3. 替换页面中的组件引用路径
      replace(
        file: configPath + &apos;/pages/**/*.json&apos;,
        from: &apos;/components/&apos;,
        to: &apos;/shared/components/&apos;
      )

      // 4. 开发模式下监听文件变化
      if (isDev) {
        watch(&apos;miniprogram/**/*&apos;, (changedFile) =&amp;gt; {
          if (changedFile.includes(&apos;pages/&apos;)) {
            copy(
              changedFile -&amp;gt;
              configPath + &apos;/&apos; + changedFile
            )
          }
          else if (changedFile.includes(&apos;components/&apos;)) {
            copy(
              changedFile -&amp;gt;
              configPath + &apos;/shared/&apos; + changedFile
            )
            replace(
              file: configPath + &apos;/pages/**/*.json&apos;,
              from: &apos;/components/&apos;,
              to: &apos;/shared/components/&apos;
            )
          }
        })
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有了这个插件，在每个项目通过配置，就能快速实现混合开发：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import uni from &quot;@dcloudio/vite-plugin-uni&quot;;
import { defineConfig } from &quot;vite&quot;;
import uniWxCopy from &quot;vite-plugin-uni-wx-copy&quot;;

export default defineConfig({
  plugins: [
    uni(),
    uniWxCopy({
      rootDir: &quot;../miniprogram&quot;,
      // 复制共享资源
      copy: [
        {
          sources: [&quot;components&quot;, &quot;static&quot;, &quot;utils&quot;],
          dest: &quot;shared/&quot;,
          shared: true,
        },
      ],
      // 主包页面
      pages: [&quot;pages/index&quot;, &quot;pages/page1&quot;, &quot;pages/page2&quot;],
      // 分包配置
      subPackages: [
        {
          root: &quot;subpackages&quot;,
          pages: [&quot;detail&quot;],
        },
      ],
      // 重写 app.json 以添加全局组件
      rewrite: [
        {
          file: &quot;app.json&quot;,
          write: code =&amp;gt; {
            const appJson = JSON.parse(code);
            appJson.usingComponents = {
              ...appJson.usingComponents,
              &quot;app-btn&quot;: &quot;/shared/components/app-btn/app-btn&quot;,
            };
            return JSON.stringify(appJson, null, 2);
          },
        },
      ],
    }),
  ],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还需要解决一个关键问题：原生小程序页面与 uni-app 主体之间的状态共享，包括环境配置、用户信息等运行时数据。&lt;/p&gt;
&lt;p&gt;由于不同项目的业务场景各不相同，这里采用了一种可定制的状态共享方案：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;利用小程序全局实例 &lt;code&gt;getApp()&lt;/code&gt; 作为跨技术栈的通信桥梁，在 uni-app 项目中实现状态管理和更新的核心逻辑&lt;/li&gt;
&lt;li&gt;在构建过程中，通过字符串匹配，将原生项目中的方法替换成 &lt;code&gt;getApp()&lt;/code&gt; 提供的统一接口&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;举个例子，将原本的鉴权方法改成通过 getApp 使用 uni-app 项目提供的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// auth.getUserInfo( -&amp;gt; auth.getApp().getUserInfo(
{
  replaceRules: {
    from: /auth.getUserInfo\(/g,
    to: &apos;getApp().getUserInfo(&apos;,
    files: [
      ...pages.map(page =&amp;gt; path.join(configPath, page, &apos;**/*.js&apos;)),
      ...shared.sources.map(dir =&amp;gt; path.join(configPath, shared.dest, dir, &apos;**/*.js&apos;)),
    ],
  },
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再举个例子，动态修改原生项目的开发环境：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  rewrite: [
    {
      file: &quot;shared/config/index.js&quot;,
      write: code =&amp;gt; {
        // eslint-disable-next-line no-param-reassign
        code = code.replace(
          /export const DEV =(.+)/,
          `export const DEV = ${mode === &quot;development&quot; ? &quot;true&quot; : &quot;false&quot;}`
        );

        return code;
      },
    },
  ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有兴趣可以看看这个插件：&lt;a href=&quot;https://github.com/gd4Ark/vite-plugin-uni-wx-copy&quot;&gt;vite-plugin-uni-wx-copy&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;uni-app 项目集成到原生小程序&lt;/h2&gt;
&lt;p&gt;这种方案核心思路是：将 uni-app 项目的构建产物集成到原生小程序项目中，并注册页面。&lt;/p&gt;
&lt;p&gt;没错，就是与上面的方案反过来，这也是得益于 uni-app 项目提供了一个打包方式：混合分包。&lt;/p&gt;
&lt;p&gt;简单说就是将一个 uni-app 项目打包成小程序的一个分包，满足以下场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;既可以实现将功能集成到现有的小程序项目中，同时支持分发到 APP、H5 等&lt;/li&gt;
&lt;li&gt;微信小程序单个分包限制为 2M，可按需拆分多个分包，且不影响其它平台分发&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;假设目录结构如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.
├── miniprogram/          # 原生微信小程序项目目录
│   ├── app.js
│   ├── app.json
│   └── ...
└── uni-app-project/     # uni-app 项目目录
    ├── src/
    ├── vite.config.ts
    └── ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 uni-app 项目的 &lt;code&gt;package.json&lt;/code&gt; 中配置分包构建命令（根据情况决定是否需要多个分包）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;run-p &apos;dev:**&apos;&quot;,
    &quot;build&quot;: &quot;run-p &apos;build:**&apos;&quot;,
    &quot;dev:pkg-a&quot;: &quot;uni -p pkg-a --subpackage=pkg-a&quot;,
    &quot;build:pkg-a&quot;: &quot;uni build -p pkg-a --subpackage=pkg-a&quot;,
    &quot;dev:pkg-b&quot;: &quot;uni -p pkg-b --subpackage=pkg-b&quot;,
    &quot;build:pkg-b&quot;: &quot;uni build -p pkg-b --subpackage=pkg-b&quot;
  },
  &quot;uni-app&quot;: {
    &quot;scripts&quot;: {
      &quot;pkg-a&quot;: {
        &quot;title&quot;: &quot;pkg-a&quot;,
        &quot;env&quot;: {
          &quot;UNI_PLATFORM&quot;: &quot;mp-weixin&quot;
        },
        &quot;define&quot;: {
          &quot;MP-PKG-A&quot;: true
        }
      },
      &quot;pkg-b&quot;: {
        &quot;title&quot;: &quot;pkg-b&quot;,
        &quot;env&quot;: {
          &quot;UNI_PLATFORM&quot;: &quot;mp-weixin&quot;
        },
        &quot;define&quot;: {
          &quot;MP-PKG-B&quot;: true
        }
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 uni-app 项目的 pages.json 中使用条件编译配置分包页面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;pages&quot;: [
    // #ifdef MP-PKG-A
    {
      &quot;path&quot;: &quot;pages/index/index&quot;
    },
    // #endif
    // #ifdef MP-PKG-B
    {
      &quot;path&quot;: &quot;pages/detail/index&quot;
    }
    // #endif
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该 Vite 插件需要实现以下核心功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;构建阶段：将 uni-app 的构建产物放到原生小程序项目中&lt;/li&gt;
&lt;li&gt;开发体验：uni-app 热更新时，更新差异部分&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;伪代码实现，大概流程就是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function uniSubpackageCopyPlugin(options) {
  return {
    name: &quot;vite-plugin-uni-subpackage-copy&quot;,
    // 在其他插件之后执行
    enforce: &quot;post&quot;,

    // 构建完成后执行
    async writeBundle(output) {
      // 1. 如果配置了重写规则，先处理文件重写
      if (options.rewrite) {
        for (const rule of options.rewrite) {
          // 读取文件
          const content = readFile(rule.file);
          // 使用重写函数处理内容
          const newContent = rule.write(content);
          // 写入新内容
          writeFile(rule.file, newContent);
        }
      }

      // 2. 使用 rsync 将分包文件从 uni-app 构建目录同步到原生项目
      rsync({
        from: options.subpackageDir, // uni-app 分包构建目录
        to: options.rootDir, // 原生项目目录
        // 保持文件属性，递归同步，压缩传输
        flags: &quot;avz&quot;,
        // 删除目标目录中源目录没有的文件
        delete: true,
      });
    },
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 uni-app 项目的 vite.config.ts 中配置插件：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import uni from &quot;@dcloudio/vite-plugin-uni&quot;;
import { defineConfig } from &quot;vite&quot;;
import uniSubpackageCopy from &quot;vite-plugin-uni-subpackage-copy&quot;;

export default defineConfig({
  plugins: [
    uni(),
    process.env.UNI_PLATFORM === &quot;mp-weixin&quot; &amp;amp;&amp;amp;
      uniSubpackageCopy({
        rootDir: &quot;../miniprogram&quot;,
        subpackageDir: process.env.UNI_SUBPACKAGE,
      }),
  ],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有兴趣可以看看这个插件：&lt;a href=&quot;https://github.com/gd4Ark/vite-plugin-uni-subpackage-copy&quot;&gt;vite-plugin-uni-subpackage-copy&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>我如何自己实现 lint-staged</title><link>https://4ark.me/posts/2025-10-13-lint-staged/</link><guid isPermaLink="true">https://4ark.me/posts/2025-10-13-lint-staged/</guid><description>都 2025 年了还在聊 lint-staged？确实有点复古。但最近实在闲得慌，博客都快长草了，索性把过去几年踩过的坑和折腾过的轮子翻出来炒冷饭。接下来会陆续更新一系列文章，就当是给自己这几年的搬砖生涯做个总结。</description><pubDate>Mon, 13 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;都 2025 年了还在聊 lint-staged？确实有点复古。但最近实在闲得慌，博客都快长草了，索性把过去几年踩过的坑和折腾过的轮子翻出来炒冷饭。接下来会陆续更新一系列文章，就当是给自己这几年的搬砖生涯做个总结。&lt;/p&gt;
&lt;h2&gt;为什么要自己实现 lint-staged？&lt;/h2&gt;
&lt;p&gt;说来惭愧，我司用的不是 git，而是 Mercurial (hg) 这个「冷门」版本控制工具。所以很自然地，git 生态下的各种神器都用不了。2022 年写过一篇 &lt;a href=&quot;https://4ark.me/posts/2022-03-17-hg-hooks/&quot;&gt;《Hg hooks 实践历程》&lt;/a&gt;，主要就是给 hg 造了个 husky 轮子，结果被领导看上了，在公司内推广使用。既然都做到这份上了，lint-staged 自然也被提上了日程。&lt;/p&gt;
&lt;p&gt;简单来说，lint-staged 就是专门处理暂存区（staged）文件的格式化和 lint 操作。这样做有两个好处：一是速度快，二是不会被那些还没提交的本地改动搞出来的 lint 错误打断提交。&lt;/p&gt;
&lt;p&gt;但问题来了，hg 压根就没有暂存区这玩意儿！这就是我实现 lint-staged 时遇到的最大挑战。&lt;/p&gt;
&lt;h2&gt;实现思路&lt;/h2&gt;
&lt;p&gt;先回忆一下 lint-staged 的标准配置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;scripts&quot;: {
    &quot;lint-staged&quot;: &quot;lint-staged&quot;
  },
  &quot;lint-staged&quot;: {
    &quot;**/*.{js,vue}&quot;: [&quot;eslint --cache --fix&quot;, &quot;pnpm dts&quot;],
    &quot;**/*.{css,vue}&quot;: &quot;stylelint --cache --fix&quot;,
    &quot;**/*.{json,d.ts,md}&quot;: &quot;prettier --write&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在 &lt;code&gt;pre-commit&lt;/code&gt; 钩子里执行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# .husky/pre-commit

npx lint-staged
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;标准流程是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在 &lt;code&gt;pre-commit&lt;/code&gt; 阶段执行 lint-staged&lt;/li&gt;
&lt;li&gt;通过 git 命令获取暂存区的文件列表，对这些文件按配置执行各种命令&lt;/li&gt;
&lt;li&gt;把产生的文件改动重新加到暂存区，合并到本次提交&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;但 hg 没有暂存区，没法像 git 那样直接获取文件列表，只能另辟蹊径。虽然官方 lint-staged 在 &lt;code&gt;pre-commit&lt;/code&gt; 阶段执行，但在 hg 中我们可以改到 &lt;code&gt;pretxncommit&lt;/code&gt; 阶段，用 &lt;code&gt;hg export tip --template &quot;{file_adds} {file_mods}&quot;&lt;/code&gt; 命令获取本次提交涉及的文件列表。&lt;/p&gt;
&lt;p&gt;lint-staged 的操作会改动文件，需要把产生的改动合并到本次提交中。在 hg 里可以用 &lt;code&gt;(HUSKY=0 hg commit $files --amend -m &quot;$commit_message&quot;) &amp;gt;/dev/null 2&amp;gt;&amp;amp;1&lt;/code&gt; 命令把最新改动合并到本次提交。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;PS：这里需要 HUSKY=0 是因为本次提交不需要再经过 lint-staged，不然就会死循环。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;到这里其实已经可以做到官方 lint-staged 的核心功能，基本够用了。但我们还有个额外需求：有些项目会对 &lt;code&gt;.js&lt;/code&gt; 文件执行 &lt;code&gt;pnpm dts&lt;/code&gt; 操作，这会产生新文件或删除文件，我们希望把这些 lint-staged 操作导致的文件改动也合并到本次提交中。&lt;/p&gt;
&lt;p&gt;举个例子，我们有 &lt;code&gt;a.js&lt;/code&gt;，经过 &lt;code&gt;pnpm dts&lt;/code&gt; 后会产生 &lt;code&gt;types/a.d.ts&lt;/code&gt;，我们想把这个文件也合并到提交中。所以设计了一个硬编码命令 &lt;code&gt;hg commit&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;lint-staged&quot;: {
   &quot;**/*.{js,vue}&quot;: [
     &quot;eslint --fix&quot;,
     &quot;pnpm dts&quot;,
     &quot;hg commit&quot;
   ],
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当遇到 &lt;code&gt;hg commit&lt;/code&gt; 时，就获取最新的文件改动列表，与之前拿到的文件改动列表做 diff，差异部分就是本次操作产生的改动。把这部分差异存到临时文件里，附加到 &lt;code&gt;$files&lt;/code&gt; 中，就能实现想要的效果。&lt;/p&gt;
&lt;h2&gt;核心代码&lt;/h2&gt;
&lt;p&gt;最后附上核心实现，首先是 &lt;code&gt;lint-staged.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env node

/* eslint-disable no-console,no-await-in-loop */

const fs = require(&quot;fs&quot;);
const { exec, execSync: _execSync } = require(&quot;child_process&quot;);
const path = require(&quot;path&quot;);

const glob = require(&quot;glob&quot;);

const cwd = process.cwd();

// 获取 hg 仓库根目录路径
const ROOT_PATH = execSync(&quot;hg root&quot;);
const PACKAGE_JSON_PATH = path.join(cwd, &quot;package.json&quot;);

// 临时文件路径，用于存储 lint-staged 操作产生的文件改动
const LINT_STAGED_MODIFIED_PATH = path.join(
  ROOT_PATH,
  &quot;.hghusky/_&quot;,
  &quot;LINT_STAGED_MODIFIED&quot;
);

// 硬编码的特殊命令，用于触发文件改动检测
const COMMIT_COMMAND = &quot;hg commit&quot;;

main();

async function main() {
  try {
    // 组合函数：读取配置 -&amp;gt; 生成命令 -&amp;gt; 执行命令
    const tasks = compose(
      getLintStagedConfig,
      generateCommands,
      executeCommands
    );

    await tasks();
  } catch (error) {
    console.error(error);
    process.exit(1);
  }

  process.exit(0);
}

/**
 * 从 package.json 中读取 lint-staged 配置
 */
function getLintStagedConfig() {
  const packageJsonContent = fs.readFileSync(PACKAGE_JSON_PATH, &quot;utf-8&quot;);
  const packageJson = JSON.parse(packageJsonContent);
  const lintStagedConfig = packageJson[&quot;lint-staged&quot;];

  if (!lintStagedConfig) {
    throw new Error(&quot;No lint-staged config found&quot;);
  }

  return lintStagedConfig;
}

/**
 * 根据配置生成需要执行的命令
 * 核心逻辑：匹配本次提交的文件与配置的 glob 模式
 */
function generateCommands(config) {
  // 获取本次提交涉及的文件列表（新增和修改的文件）
  const committedFiles = execSync(
    &apos;hg export tip --template &quot;{file_adds} {file_mods}&quot;&apos;
  ).split(&quot; &quot;);
  const commands = [];

  Object.entries(config).forEach(([pattern, command]) =&amp;gt; {
    // 1. 使用 glob 匹配所有符合模式的文件
    // 2. 转换为相对于仓库根目录的路径
    // 3. 过滤出本次提交涉及的文件
    // 4. 转换为相对于当前工作目录的路径
    const matchedFiles = glob
      .sync(pattern, { nodir: true })
      .map(file =&amp;gt; path.relative(ROOT_PATH, path.resolve(cwd, file)))
      .filter(file =&amp;gt; committedFiles.includes(file))
      .map(file =&amp;gt; path.relative(cwd, path.resolve(ROOT_PATH, file)))
      .join(&quot; &quot;);

    if (matchedFiles) {
      commands.push({ command, files: matchedFiles });
    }
  });

  return commands;
}

/**
 * 执行生成的命令列表
 * 关键特性：支持检测命令执行前后的文件变化
 */
async function executeCommands(commands) {
  await Promise.all(
    commands.map(async item =&amp;gt; {
      if (!item.files.length) return;

      // 如果命令是数组（包含多个子命令）
      if (Array.isArray(item.command)) {
        // 检查是否包含特殊的 hg commit 命令
        const needCommit = item.command.find(cmd =&amp;gt; cmd === COMMIT_COMMAND);

        // 如果需要检测文件变化，先记录当前的文件状态
        const prevModifyFiles = needCommit ? getModifyFiles() : [];

        // 依次执行每个子命令
        for (const subCommand of item.command) {
          if (subCommand === COMMIT_COMMAND) {
            // 遇到 hg commit 命令时，计算文件变化
            const afterModifyFiles = diff(getModifyFiles(), prevModifyFiles);

            // 如果有新产生的文件改动，写入临时文件
            if (afterModifyFiles.length) {
              fs.writeFileSync(
                LINT_STAGED_MODIFIED_PATH,
                afterModifyFiles.join(&quot; &quot;)
              );
            }
          } else {
            await executeCommand(`${subCommand} ${item.files}`);
          }
        }

        return;
      }

      await executeCommand(`${item.command} ${item.files}`);
    })
  );
}

function executeCommand(commandWithFiles) {
  return new Promise(resolve =&amp;gt; {
    console.log(`[lint-staged] execute: ${commandWithFiles}`);
    const child = exec(commandWithFiles, (error, _stdout, stderr) =&amp;gt; {
      if (error) {
        console.warn(stderr);
        process.exit(1);
      } else {
        resolve();
      }
    });

    // 将子进程的输出重定向到当前进程
    child.stderr.pipe(process.stderr);
    child.stdout.pipe(process.stdout);
  });
}

// eslint-disable-next-line consistent-return
function execSync(command) {
  try {
    const result = _execSync(command, { encoding: &quot;utf-8&quot; });

    return result.trim();
  } catch (error) {
    console.error(`[lint-staged] execute ${command} error: ${error}`);

    process.exit(1);
  }
}

function compose(...functions) {
  return async () =&amp;gt; {
    let result;
    for (const func of functions) {
      result = await func(result);
    }

    return result;
  };
}

/**
 * 获取当前工作目录中所有修改过的文件列表
 * 用于检测 lint-staged 操作产生的文件变化
 */
function getModifyFiles() {
  return execSync(&quot;hg status | sort&quot;)
    .split(&quot;\n&quot;)
    .map(line =&amp;gt; line.replace(/^.*?\s/, &quot;&quot;));
}

function diff(array1, array2) {
  return array1.filter(item =&amp;gt; !array2.includes(item));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最关键的就是会在遇到 &lt;code&gt;hg commit&lt;/code&gt; 命令的时候，产生一个临时文件 &lt;code&gt;LINT_STAGED_MODIFIED_PATH&lt;/code&gt;，它就是本次操作改动的文件列表，然后在 &lt;code&gt;commit&lt;/code&gt; 阶段将其附加到 &lt;code&gt;$files&lt;/code&gt; 合并到本次提交：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash

HUSKY_DIR=&apos;.hghusky&apos;

# 在上级目录的 commit 执行之前，先做一些初始化工作
commit_message=$(hg tip --template &apos;{desc}&apos;)

# 将 lint-staged 产生的变更重新添加到当前 commit 中
function mergeAutoFixed2Commit() {
  if [ ! -f &quot;$HUSKY_DIR/pretxncommit&quot; ]; then
    exit 0
  fi

  skipLint

  files=$(hg export tip --template &apos;{files}&apos;)

  # 提交 lint-staged 阶段改动的文件
  LINT_STAGED_MODIFIED_PATH=&quot;$HUSKY_DIR/_/LINT_STAGED_MODIFIED&quot;
  if [ -f &quot;$LINT_STAGED_MODIFIED_PATH&quot; ]; then
    modified_files=$(cat &quot;$LINT_STAGED_MODIFIED_PATH&quot;)
    files=&quot;$files $modified_files&quot;

    rm &quot;$LINT_STAGED_MODIFIED_PATH&quot;
  fi

  set +e
  # shellcheck disable=SC2086
  hg addremove $modified_files
  # shellcheck disable=SC2086
  (HUSKY=0 hg commit $files --amend -m &quot;$commit_message&quot;) &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
  set -e
}

if [ -f &quot;$HUSKY_DIR/pretxncommit&quot; ]; then
  mergeAutoFixed2Commit
fi
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>2024 年终总结</title><link>https://4ark.me/posts/2024-12-23-2024-summary/</link><guid isPermaLink="true">https://4ark.me/posts/2024-12-23-2024-summary/</guid><description>“今年是过去十年最糟糕的一年，但可能是未来十年最好的一年。”在 2019 年，这句带着预言性质的话，当时我还未完全理解其深意。但如今站在 2024 年回望，竟显得如此贴切，甚至更具分量。</description><pubDate>Tue, 31 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;前言&lt;/h2&gt;
&lt;p&gt;“今年是过去十年最糟糕的一年，但可能是未来十年最好的一年。”在 2019 年，这句带着预言性质的话，当时我还未完全理解其深意。但如今站在 2024 年回望，竟显得如此贴切，甚至更具分量。&lt;/p&gt;
&lt;p&gt;在经济下行、行业调整的大环境下，谁的日子都不好过。许多公司都在减员，失业的人也越来越多，这并非个人努力所能轻易改变，而是整个行业，整个社会都在经历一段艰难时期。&lt;/p&gt;
&lt;p&gt;于我个人而言，今年似乎是凝固的一年，各方面都乏善可陈，几乎没有任何的改变。然而，在这充满不确定性的环境下，或许这种“不变”本身竟也未尝不是一种幸运。它意味着，尽管没有迎来期待中的跃升，但至少也没有滑向更深的低谷，还能维持着现有的状态。&lt;/p&gt;
&lt;p&gt;放眼四周，或许这也是许多普通人共同的感受，我们虽不能左右大势，但至少可以努力过好自己的生活，在力所能及的范围内做出改变。我深知自己无力也无意去探讨那些宏大的社会命题，眼下更应关注的是如何在不确定的环境中自洽地生活，努力地生长。所以，我选择记录自己，因为“记录”，本身就是对抗时间流逝、对抗虚无的一种方式，即使是再平凡不过的一年，也值得被认真对待、被仔细书写。&lt;/p&gt;
&lt;p&gt;因此，我将从以下几个方面，记录下属于我的 2024 年。&lt;/p&gt;
&lt;h2&gt;工作&lt;/h2&gt;
&lt;p&gt;首先从工作方面说起。今年我接触到了不少新的挑战和领域，这无疑是一件好事。毕竟，我从来不是、也不希望自己成为那种“一年经验用十年”的人，持续学习和成长才是我所追求的。然而，大环境的浪潮也波及到了公司，陆陆续续的裁员消息让人倍感无奈。身处其中，除了更加努力地做好本职工作，不断提升自身价值，似乎也没有更好的应对之法。&lt;/p&gt;
&lt;p&gt;即便如此，今年在工作中依然遇到了一件颇有意思的事，也算是一种调剂。一位同事向我询问有什么好用的工具推荐，或许这只是他不经意的一问，但却触动了我。那一刻，我意识到自己在他人眼中，或许已然成为了一个善于利用工具提升效率的人，这让我既有些许得意，又感到一丝惭愧。因为被这突如其来的一问，我竟一时语塞，只能随便找了个理由应付过去。但事后，我暗下决心，一定要专门写一篇文章，好好整理一下我正在使用的工具。这不仅仅是为了回应那位同事的期待，更是为了对自己过去一段时间工具使用情况的梳理和总结，因此有了这篇文章&lt;a href=&quot;/posts/2024-12-17-2024-a2z/&quot;&gt;《2024 年使用的工具从 A 到 Z》&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;其实，在日复一日的工作中，类似这样的小事还有很多，若用心留意，便能从中汲取养分。一份工作做得久了，难免会陷入枯燥的循环，热情也随之消磨殆尽。因此，我们需要主动去寻找一些能够点燃内心、让自己持续获得满足感的事情。这种满足感并非仅仅来源于完成日常的任务，而是源于内心的充盈，源于那些真正让自己觉得有意义、有价值，能让自己或他人变得更好的事情。&lt;/p&gt;
&lt;h2&gt;学习&lt;/h2&gt;
&lt;p&gt;接下来本应谈谈学习，但老实说，今年我在工作之外的时间里，并没有进行严格意义上的学习。不过，我也并非完全虚度，而是将精力投入到了各种“折腾”之中。而谁又能说，这些看似“不务正业”的折腾，不是一种另类的学习方式呢？&lt;/p&gt;
&lt;h3&gt;电子族谱&lt;/h3&gt;
&lt;p&gt;今年清明，回乡扫墓。在老家里，长辈们聊着祖辈们的往事，我翻阅着那本泛黄的族谱，一个念头跃入脑海：我们这一辈，又有几人真正看过族谱，了解祖先的生平呢？纸质族谱固然珍贵，但往往被束之高阁，查阅与补充皆有不便。时代在变，电子族谱或许才是传承家族记忆的更好选择。&lt;/p&gt;
&lt;p&gt;我希望能用自己的方式为家族做些什么，这既是出于对历史的热爱，也是对先祖的一种尊敬。毕竟每个人百年之后不过黄土一抔，我们年年祭拜的先祖，又有多少后人能道出他们的姓名和生平？历史的魅力，正在于它记录了那些鲜活的生命，让逝者不至于完全湮没在时间的尘埃中。将族谱电子化，为每位祖先建立生平简介，或许能激发年轻一辈的兴趣。纵然无法让每个人都产生共鸣，至少也遂了我个人的心愿。我更希望，将来我的后代能够通过这份电子族谱，了解自己的血脉源流，知道自己从何处来。&lt;/p&gt;
&lt;p&gt;当然，我无法像司马迁那样为每位祖先著书立传。他们世代为农，平凡一生，能记录的或许只有姓名、生卒、婚配和安葬地。然而，这些看似简单的信息，串联起来，便构成了家族的历史长卷，也证明了他们曾经鲜活地存在过。在整理这些信息的过程中，我自身也受益匪浅，了解到了更多姓氏起源、堂号分布、乃至寻根溯源的知识，更对族谱编修有了更深的体会。这并非简单的信息录入，为了撰写先祖的生平，我需要对不同来源的记载进行仔细的考究和推理，辨别真伪，有时还需要翻阅当时的县志，去还原那时的社会风貌。或许有些先祖的生卒年份和源流已不可考，但我仍秉持“成功不必在我”的信念，尽己所能记录现有资料，留待后人去完善。这段经历，让我初窥了历史学家们为研究一个人物生卒年月所做的大量工作，体会到那种求真务实的严谨，也更加坚定了我做好这份电子族谱的决心。&lt;/p&gt;
&lt;h3&gt;电子阳痿&lt;/h3&gt;
&lt;p&gt;“电子阳痿”常被用来形容对电子游戏失去兴趣。对我来说，游戏的“阳痿”早已开始。年少时，我曾是个“网瘾少年”，逃课去网吧也是常事。不知何时起，游戏的热情悄然消退。还记得刚工作时，同事们休息时间捧着 Switch 酣战，而我却提不起丝毫兴趣。&lt;/p&gt;
&lt;p&gt;与游戏相对的，是我曾经对折腾电子产品的狂热。如果说游戏是我年少时的一项爱好，那么刷机则是另一项。小时候电子设备有限，手机便成了我最好的玩具。为了研究刷机，我废寝忘食，线刷、卡刷、救砖，这些身边人闻所未闻的词汇，于我却充满魔力。后来，能接触的电子产品多了，从手机、电脑到路由器，只要能刷系统的，我都乐此不疲地折腾一遍。&lt;/p&gt;
&lt;p&gt;这其中，最让我印象深刻的是在学校参加市级比赛时的一段经历。其中需要练习配置思科网络设备，出于“折腾”的本能，我给工作室的一台无线 AP 升级了固件，结果不幸变砖，无法正常启动。当时的我吓得不轻，担心被开除，于是没有告诉老师。还记得那是一个周五的下午，放学后的我怀着无比沉重的心情回了家。整个周末，我都茶饭不思，泡在论坛里寻找解决方案，可谓“寝食难安”。所幸皇天不负有心人，终于找到了救砖的方法。一回学校，我便第一时间奔向工作室，成功救活了那台 AP。相信每个喜欢折腾的人，都经历过类似的苦与乐。“折腾”的过程总是充满挑战，甚至伴随着痛苦的“翻车”，但当你最终解决问题时，那种成功的喜悦和满足感也无与伦比。&lt;/p&gt;
&lt;p&gt;然而近两年，这份对电子产品的热情也逐渐冷却。曾经热衷的钻研和尝试，如今都变得兴致索然。我并非完全不再折腾，只是和年少时相比，少了很多热情。就拿之前折腾的黑群晖来说，硬盘坏了好久，我也一直没去管它。我曾一度认为，继游戏之后，我对电子产品也“阳痿”了。&lt;/p&gt;
&lt;p&gt;今年趁着国补，我给住处添置了一台雷鸟电视，但发现观看 YouTube 颇为不便，便萌生了入手一台可玩性更高的路由器的想法。一番精挑细选后，我入手了红米 AX6000。到手后，我便迫不及待地刷入了 OpenWrt 固件，结果却事与愿违，网速极不稳定。我又尝试了两三个不同版本的固件，问题依旧。无奈之下，只得刷回官方固件。所幸我的需求在官方固件下也能得到满足，最终我选择了固化 SSH 并安装了 ShellClash。这次折腾路由器的经历，让我找回了些许久违的乐趣，但也不得不承认，曾经的我享受的是折腾本身，而现在，我只想用最简单高效的方式解决问题。&lt;/p&gt;
&lt;p&gt;年少时，有的是时间和精力，肆意挥霍，瞎折腾也是一种乐趣。但随着年龄增长，家庭、工作占满了时间，责任与压力纷至沓来，时间变得宝贵，便不再沉溺于无谓的折腾，不自觉地求稳。这或许并非是坏事，关键点如何在“折腾”与“不折腾”之间找到一个平衡点，找到那个让自己最舒适的姿态，而更重要的是，不要让自己丧失折腾的勇气和能力。&lt;/p&gt;
&lt;h2&gt;生活&lt;/h2&gt;
&lt;h3&gt;iOS 自动化&lt;/h3&gt;
&lt;p&gt;iOS 自动化（快捷指令）这玩意儿早就有了，但今年，我才真正体会到它实实在在的用处。&lt;/p&gt;
&lt;p&gt;下面就分享几个我常用的自动化操作：&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;上下班打卡&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;几年前，我就开始尝试用 iOS 快捷指令自动打卡企业微信。最初的方案是结合通勤时间设置闹钟，关闭闹钟时触发。但此方案常因通勤不稳定或需手动确认而失效。后来，我换了思路，改为特定时间内，手机连接/断开公司 Wi-Fi 即自动打开企业微信打卡。改进后方案出奇地稳定，打卡再也没落下过。&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;取快递提醒&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;以前下班回家取快递，总要翻找半天短信，甚至打开购物 APP 才能找到取件码，费时费力，让人心烦。后来我做了个自动化：只要短信里有“包裹”之类的关键词，就自动把取件码提取出来，塞进提醒事项里。我还加了个触发条件，每次刷羊城通出站的时候，自动弹出提醒，如此一来，取快递就方便多了。&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;复制验证码&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;受“取快递提醒”的启发，我将这一思路应用到了短信验证码的接收上。每当收到包含“验证码”关键词的短信，iOS 快捷指令会自动提取内容，并推送到 macOS 端的 Bark 软件实现自动复制到剪贴板。如此一来，我只需直接粘贴，省去了所有中间步骤，效率倍增。&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;更多...&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;类似的应用还有很多，比如节假日自动关闭闹钟、低电量自动开启省电模式等。这些操作看似微小，但组合起来却能实实在在地提升生活品质。这种化繁为简、自动解决问题的体验，却能让每个普通人都能从科技发展中感受到幸福感的提升。&lt;/p&gt;
&lt;h3&gt;年度盘点&lt;/h3&gt;
&lt;h4&gt;最喜欢的软件&lt;/h4&gt;
&lt;h5&gt;&lt;strong&gt;macOS&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;我在本站不止一次自诩为 RSS 重度爱好者，痴迷于“万物皆可 RSS”的境界。我甚至幻想过：要是微信朋友圈也能用 RSS 订阅该多好！因此，当集大成者的 RSS 阅读器 Follow 一经推出，我便迫不及待地四处寻求邀请码。最近得知 Follow 已经在推出移动端的 TestFlight 版本，期待明年它能成为我在 iOS 上最喜欢的软件。&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;iOS&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;今年，我将用了许久的 Shadowrocket 换成了 Quantumult X，没想到这个决定竟带来了意想不到的惊喜。Quantumult X 不仅拥有强大的去广告能力，更以其极高的可玩性著称。它支持各种自动化脚本，让你可以根据自己的需求定制各种功能，我还自己编写了几个自动签到脚本。综合来看，Quantumult X 绝对是我今年剁手最值的软件，没有之一。&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;TV&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;我从小就爱看电影，早年为了满足我对电影的渴求，我折腾了个黑群晖当下载机，全天候下载，最终硬盘也因此“阵亡”。后来，使用阿里云盘存电影，还搭了个 Alist，但为了搞个漂亮的海报墙，还得自己手动刮削，简直麻烦死了。直到后来，朋友给我安利了 Emby 公益服，从此打开新世界的大门！想看的电影基本都有，还都已经刮削好精美的海报墙，再也不用自己瞎折腾了，观影体验蹭蹭往上涨！&lt;/p&gt;
&lt;h4&gt;最喜欢的电影&lt;/h4&gt;
&lt;p&gt;原以为《周处除三害》将是我今年的年度最佳，直到年底黄子华和许冠文合作的《破地狱》上映了！说来也巧，香港首映那天我正好就在香港，但愣是给忘了，现在想起来都想抽自己！原以为这部电影无缘大陆银幕，还好 12 月宣布了定档，我赶紧第一时间买了预售票。&lt;/p&gt;
&lt;p&gt;这部电影的优秀无需我赘述，票房成绩便是最好的佐证。于我个人，除了黄子华和许冠文这两位我从小就喜欢的演员同台飙戏——尤其黄子华，他是我心中唯一的男神，看到他如今的成就，我由衷地替他开心。更何况，《破地狱》选择了一个华语电影中鲜少触及的题材——直面死亡与殡葬。我相信，每一个经历过亲人离世，并走过整个殡葬流程的人，都会对这部电影感同身受。&lt;/p&gt;
&lt;h4&gt;最喜欢的电视剧&lt;/h4&gt;
&lt;p&gt;前段时间，《白夜追凶》第二部上映的消息刷屏了各大平台，但我对这部剧却一无所知，竟从未看过第一部。于是我找出了 2017 年上映的第一部，谁知这一看便入了迷，一口气追完了全剧。然而，备受期待的第二部却槽点满满，仅看一集便让我没了继续追的欲望。&lt;/p&gt;
&lt;p&gt;这也难怪，毕竟续集难超前作已成定律，就像最近的《鱿鱼游戏》第二季，评价就褒贬不一。&lt;/p&gt;
&lt;h4&gt;最喜欢的音乐&lt;/h4&gt;
&lt;p&gt;今年有一次，在广场上偶然听到一对情侣对唱《偏爱》，这首歌我之前也听过，但那天不知道为什么，特别有感觉。回去后，我便开始单曲循环，怎么听都不腻！&lt;/p&gt;
&lt;h2&gt;回顾与展望&lt;/h2&gt;
&lt;p&gt;真正的成长，就是学会与生活和解，在平凡的日子里，活出自己的精彩。2024 年即将过去，新的一年即将到来。愿我们都能在未来的日子里，继续保持热爱，不负自己。也祝愿每一位朋友，新年快乐，万事胜意，好好睡觉，好好生活！&lt;/p&gt;
</content:encoded></item><item><title>2024，我使用的工具从 A 到 Z</title><link>https://4ark.me/posts/2024-12-17-2024-a2z/</link><guid isPermaLink="true">https://4ark.me/posts/2024-12-17-2024-a2z/</guid><description>分享我在 2024 年日常工作和生活中所使用的各类工具，从 A 到 Z 进行详细介绍。</description><pubDate>Tue, 17 Dec 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;写在前面&lt;/h2&gt;
&lt;p&gt;今年有一次，同事突然问我，可有什么好工具推荐。我一时竟怔住，不知如何作答。倒也不是没有，平日里确实积累了不少趁手的工具，只是仓促间让我说出来，倒也为难。&lt;/p&gt;
&lt;p&gt;只因每种工具有其特定的适用场景，而每个人偏好亦有不同。正所谓：我之蜜糖，彼之砒霜。&lt;/p&gt;
&lt;p&gt;事后想想，其实可以写一篇文章，把我用的工具都介绍一下。虽然数量不少，也不一定都适合每个人，但肯定有人能从中找到适合自己的。所以，我决定参考别人的方式，写一篇我自己的《从 A 到 Z》，把我 2024 年日常工作和生活中使用的各种工具，按照字母顺序一一列出来，和大家分享。&lt;/p&gt;
&lt;p&gt;以下是一些工具的分类，包括 macOS、iOS、安卓 TV 等系统软件、各类网站、浏览器扩展以及命令行工具和 npm 包。为了方便查阅，我将首先按类型分类，再在各类中按字母顺序排列。&lt;/p&gt;
&lt;h2&gt;macOS 软件&lt;/h2&gt;
&lt;h4&gt;APTV&lt;/h4&gt;
&lt;p&gt;吃饭时追电视的好帮手！支持苹果全家桶，虽不自带直播源，但配合自定义源简直绝了。&lt;/p&gt;
&lt;p&gt;我个人维护了一套包含卫视频道、港台频道和网络直播的源，如有需要可邮件联系。&lt;/p&gt;
&lt;h4&gt;Applite&lt;/h4&gt;
&lt;p&gt;免费开源的 macOS 工具，专为 Homebrew 第三方应用的安装和管理设计，操作更直观，省时省力。&lt;/p&gt;
&lt;h4&gt;Bob&lt;/h4&gt;
&lt;p&gt;Bob 是一款强大的翻译和 OCR 工具，通过选择文本并按下快捷键 (Option + D) 即可快速翻译或进行 OCR 识别，配合 PopClip 甚至可以实现无键盘操作。更重要的是，Bob 支持自定义 AI 接口和提示语，实现高度个性化的翻译、润色、解释等功能。以下是我常用的几个提示语示例：&lt;/p&gt;
&lt;h5&gt;&lt;strong&gt;1. 翻译（使用 Pro 用户专属的「智谱翻译官」）&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;点我展开提示语&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;p&gt;出处：https://x.com/dotey/status/1727091267870367880&lt;/p&gt;
&lt;p&gt;角色设定&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;你是一位精通 $query.detectToLang、$query.detectFromLang 的专业翻译，尤其擅长将专业学术论文翻译成浅显易懂的科普文章。请你帮我将以下 $query.detectFromLang 段落翻译成 $query.detectToLang，风格与 $query.detectToLang 科普读物相似。

规则：

- 翻译时要准确传达原文的事实和背景。
- 即使上意译也要保留原始段落格式，以及保留术语，例如 FLAC，JPEG 等。保留公司缩写，例如 Microsoft, Amazon, OpenAI 等。
- 人名不翻译
- 同时要保留引用的论文，例如 [20] 这样的引用。
- 对于 Figure 和 Table，翻译的同时保留原有格式，例如：“Figure 1: ”翻译为“图 1: ”，“Table 1: ”翻译为：“表 1: ”。
- 全角括号换成半角括号，并在左括号前面加半角空格，右括号后面加半角空格。
- 输入格式为 Markdown 格式，输出格式也必须保留原始 Markdown 格式
- 在翻译专业术语时，第一次出现时要在括号里面写上英文原文，例如：“生成式 AI (Generative AI)”，之后就能只写中文了。
- 以下是常见的 AI 相关术语词汇对应表（English -&amp;gt; 中文）：
  - Transformer -&amp;gt; Transformer
  - Token -&amp;gt; Token
  - LLM/Large Language Model -&amp;gt; 大语言模型
  - Zero-shot -&amp;gt; 零样本
  - Few-shot -&amp;gt; 少样本
  - AI Agent -&amp;gt; AI 智能体
  - AGI -&amp;gt; 通用人工智能

策略：

分三步进行翻译工作，并打印每步的结果：

1. 根据内容直译，保持原有格式，不要遗漏任何信息
2. 根据第一步直译的结果，指出其中存在的具体问题，要准确描述，不宜笼统的表示，也不需要增加原文不存在的内容或格式，包括不仅限于：

- 不符合中文表达习惯，明确指出不符合的地方
- 语句不通顺，指出位置，不需要给出修改意见，意译时修复
- 晦涩难懂，不易理解，可以尝试给出解释

3. 根据第一步直译的结果和第二步指出的问题，重新进行意译，保证内容的原意的基础上，使其更易于理解，更符合中文的表达习惯，同时保持原有的格式不变

返回格式如下，&quot;{xxx}&quot;表示占位符：

意译

{意译结果}

————

直译

{直译结果}

————

问题
{直译的具体问题列表}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户指令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;现在请按照上面的要求从第一行开始翻译以下内容为 $query.detectToLang：

$query.text
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;润色（使用 &lt;a href=&quot;https://github.com/openai-translator/bob-plugin-openai-polisher&quot;&gt;bob-plugin-openai-polisher&lt;/a&gt;）&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;点我展开提示语&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;请润色下面的文章，使其更加简洁，同时确保所有原因和理由依然明确。
文章的语气应保持轻松随意，而不会过于正式。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;通俗解释（使用 &lt;a href=&quot;https://github.com/openai-translator/bob-plugin-openai-translator&quot;&gt;bob-plugin-openai-translator&lt;/a&gt;）&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&amp;lt;details&amp;gt;
&amp;lt;summary&amp;gt;点我展开提示语&amp;lt;/summary&amp;gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;你是一个严谨的学者，但同时也希望用轻松有趣的方式分享知识。请将以下复杂文本用通俗易懂的口语解释出来。解释时可以适度加入一些幽默元素，例如巧妙的比喻、有趣的类比、轻松的语气等，但幽默的程度要适中，不要喧宾夺主，更不能影响对文本的准确理解。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;lt;/details&amp;gt;&lt;/p&gt;
&lt;h4&gt;Beyond Compare&lt;/h4&gt;
&lt;p&gt;虽然价格肉疼 ($49.9)，但用过之后真香！Beyond Compare 是一款强大的文件和文件夹对比工具，支持各种文件类型，包括 PDF。对比目录下的文件差异效果极好，还能解决代码冲突。&lt;/p&gt;
&lt;p&gt;最近出了 v5，但 v4 已经足够用了，不必升级。&lt;/p&gt;
&lt;h4&gt;Bitwarden&lt;/h4&gt;
&lt;p&gt;之前从来不用密码管理器，但入职这家公司后被强制要求，才发现它的好用之处。&lt;/p&gt;
&lt;p&gt;我选了 Bitwarden，起初打算自建服务，但官方版本已经完全够用。&lt;/p&gt;
&lt;p&gt;如果你还没用过密码管理器，强烈推荐开始用，安全又方便。&lt;/p&gt;
&lt;h4&gt;Charles&lt;/h4&gt;
&lt;p&gt;macOS 抓包必备神器，用过的都懂，不用多说。&lt;/p&gt;
&lt;h4&gt;ClashX Pro&lt;/h4&gt;
&lt;p&gt;和 Charles 类似的工具，如果不是用这个，应该也会用类似的东西。&lt;/p&gt;
&lt;h4&gt;Command X&lt;/h4&gt;
&lt;p&gt;macOS 原生不支持 Command + X 文件剪切，但装了它就可以，补齐功能缺失。&lt;/p&gt;
&lt;h4&gt;Cursor&lt;/h4&gt;
&lt;p&gt;VSCode 改造而来的 AI 代码编辑器，初体验还不错，但免费期过后价格略贵。&lt;/p&gt;
&lt;p&gt;后来接入 deepseek API 用了一段时间，最近转投 Windsurf，体验更棒。&lt;/p&gt;
&lt;h4&gt;Dash&lt;/h4&gt;
&lt;p&gt;查文档利器，配合 Raycast 使用效率爆表。&lt;/p&gt;
&lt;h4&gt;DevUtils&lt;/h4&gt;
&lt;p&gt;开发者工具合集，小而全，配合 Raycast 更好用。&lt;/p&gt;
&lt;h4&gt;Fileball&lt;/h4&gt;
&lt;p&gt;现代化的多媒体播放器，我主要当 Emby 客户端用，跨设备看 Emby 影视非常方便。&lt;/p&gt;
&lt;h4&gt;FixTim&lt;/h4&gt;
&lt;p&gt;macOS 故障救星！Wi-Fi 连不上？iCloud 同步出错？别急着重启，试试它能不能修好。&lt;/p&gt;
&lt;h4&gt;Follow&lt;/h4&gt;
&lt;p&gt;刷屏社区的 RSS 阅读器，支持电脑端和网页端，体验极佳。&lt;/p&gt;
&lt;p&gt;这里安利一下我的梗图 List：&lt;a href=&quot;https://app.follow.is/share/lists/63834202984090624?view=1&quot;&gt;梗图列表&lt;/a&gt;，有兴趣可以订阅！&lt;/p&gt;
&lt;h4&gt;Function Key Pro&lt;/h4&gt;
&lt;p&gt;Fn 键增强工具，让 F1~F12 支持短按、长按和 Fn + 短按三种操作，功能自定义随心所欲。&lt;/p&gt;
&lt;p&gt;比如双手腾不出来时，长按 F11 就能调低音量，非常实用。&lt;/p&gt;
&lt;h4&gt;Hidden Bar&lt;/h4&gt;
&lt;p&gt;开源的状态栏图标隐藏工具，让状态栏更简洁。&lt;/p&gt;
&lt;h4&gt;IINA&lt;/h4&gt;
&lt;p&gt;国产开源播放器，体验很棒，但我用得不多。&lt;/p&gt;
&lt;h4&gt;Itsycal&lt;/h4&gt;
&lt;p&gt;菜单栏日历工具，能直接查看日程安排，操作简单，颜值在线。&lt;/p&gt;
&lt;h4&gt;kitty&lt;/h4&gt;
&lt;p&gt;之前一直用 iTerm2 作为主力终端，但今年用着总觉得有点不顺手，于是试着折腾了几款新的终端工具。最后选中了 kitty，简单介绍下我的配置和体验。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;快速呼出终端：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;上学时，我用 Linux Mint 作为主力系统，特别喜欢一个叫 Tilda 的终端工具，它有个超酷的功能：不管你当前在哪个应用窗口，按下 F11 就能瞬间呼出终端窗口。后来切换到 macOS 后，这个习惯一直保留着，只不过工具换成了 iTerm2，现在是 kitty。&lt;/p&gt;
&lt;p&gt;在 kitty 里，我用 Raycast 配合快捷键来呼出终端，按下 Option + F11，窗口立刻出现，而且占满屏幕的上半部分。虽然 kitty 不原生支持快捷键显示窗口，但这个搭配已经非常顺手了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;窗口尺寸配置：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;initial_window_width 54c
initial_window_height 10c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;快捷键适配：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;为了保持之前用 iTerm2 的操作习惯，我也在 kitty 里折腾出了一些相似的快捷键。比如，按下 cmd + d 就能快速分割一个 panel。用起来几乎无缝衔接，效率直接拉满。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;map cmd+1 goto_tab 1
map cmd+2 goto_tab 2
map cmd+3 goto_tab 3
map cmd+4 goto_tab 4
map cmd+5 goto_tab 5
map cmd+6 goto_tab 6
map cmd+7 goto_tab 7
map cmd+8 goto_tab 8
map cmd+9 goto_tab 9
map cmd+0 goto_tab 10

map cmd+enter toggle_fullscreen

enabled_layouts splits,stack

map cmd+d launch --cwd=current --location=vsplit
map cmd+shift+d launch --cwd=current --location=hsplit
map cmd+w close_window
map cmd+t new_tab_with_cwd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;搜索功能：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;kitty 不支持用 cmd + f 快捷键来搜索输出的内容。因此从网上找了两种方法实现搜索功能。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# fzf
map ctrl+f launch --type=overlay --stdin-source=@screen_scrollback /opt/homebrew/bin/fzf --no-sort --no-mouse --exact -i
# https://github.com/trygveaa/kitty-kitten-search
map cmd+f launch --allow-remote-control kitty +kitten kitty/kitty_search/search.py @active-kitty-window-id
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;KeyboardHolder&lt;/h4&gt;
&lt;p&gt;一款根据不同应用、不同网站、不同场景自动切换到对应的输入法的辅助工具。&lt;/p&gt;
&lt;h4&gt;Latest&lt;/h4&gt;
&lt;p&gt;可以帮你快速检查系统中有哪些软件需要更新。&lt;/p&gt;
&lt;h4&gt;LICEcap&lt;/h4&gt;
&lt;p&gt;开源的 GIF 屏幕录制小工具，用它就够了。&lt;/p&gt;
&lt;h4&gt;LocalSend&lt;/h4&gt;
&lt;p&gt;免费、开源、跨平台的文件分享工具。&lt;/p&gt;
&lt;h4&gt;Logseq&lt;/h4&gt;
&lt;p&gt;近几年，「第二大脑」这个概念非常火爆，简单来说，就是将要记住的信息存储在笔记工具里，把大脑留给思考和创造。&lt;/p&gt;
&lt;p&gt;支持反向链接（Backlink）的笔记软件是最适合用作第二大脑的工具，你只需输入关键词，就能轻松回溯相关内容，拓展思维。&lt;/p&gt;
&lt;p&gt;Logseq 就是这种类型的优秀代表。虽然它并不是所有人心目中的“最佳选择”，但它高度适配「输入 → 整理 → 回顾」的流程。&lt;/p&gt;
&lt;p&gt;提醒：别陷入折腾软件的死循环，选一个顺手的工具，专注输入和输出才是正道！&lt;/p&gt;
&lt;h4&gt;Marta&lt;/h4&gt;
&lt;p&gt;macOS 自带的 Finder（访达）一向饱受诟病，功能简单又鸡肋。这时，Marta 就能解救你！它支持多栏操作，非常方便进行文件拷贝等操作。如果你想试试其他选择，QSpace 也是不错的替代方案。&lt;/p&gt;
&lt;h4&gt;Microsoft Edge Dev&lt;/h4&gt;
&lt;p&gt;这几年，我的主力浏览器一直是 Microsoft Edge，尤其是 Dev 版本。&lt;/p&gt;
&lt;p&gt;尝试过其他浏览器（Arc）后，我还是回到了 Edge，毕竟熟悉的工具才是最顺手的。&lt;/p&gt;
&lt;h4&gt;Notion&lt;/h4&gt;
&lt;p&gt;虽然现在我的笔记已经迁移到 Logseq，但 Notion 强大的数据库功能依旧让我无法割舍。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;收藏管理：配合浏览器扩展 Save to Notion，轻松将文章和网站收集整理。&lt;/li&gt;
&lt;li&gt;数据存储：复杂的信息和资源管理，Notion 的数据库能力仍然无可替代。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;OrbStack&lt;/h4&gt;
&lt;p&gt;如果你还在用 Docker Desktop 或 Colima，那么恭喜你发现了更好的选择：OrbStack！&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;轻量快速：启动速度快，资源占用少。&lt;/li&gt;
&lt;li&gt;操作顺滑：整体体验比 Docker Desktop 流畅许多，绝对让你用过之后后悔没早一点发现它。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Parallels Desktop&lt;/h4&gt;
&lt;p&gt;macOS 上最好用的虚拟机软件，没有之一。&lt;/p&gt;
&lt;p&gt;由于我的电脑只有可怜的 512GB，因此我选择将虚拟的 Windows 11 安装在移动硬盘上。&lt;/p&gt;
&lt;h4&gt;PicGo&lt;/h4&gt;
&lt;p&gt;一个图床上传工具。&lt;/p&gt;
&lt;h4&gt;Poe&lt;/h4&gt;
&lt;p&gt;这个平台集成了多种 AI 模型，价格相比官方渠道便宜不少。我自己没有订阅，只是偶尔用用每天免费的额度，感觉已经够用了。&lt;/p&gt;
&lt;h4&gt;RapidAPI&lt;/h4&gt;
&lt;p&gt;前身是 Paw，当初限免时下载的，现在一直在用。相比 Postman，它用起来顺手多了。&lt;/p&gt;
&lt;h4&gt;Raycast&lt;/h4&gt;
&lt;p&gt;相信不用我介绍了，但如果你真心不知道这款软件，那么我强烈推荐你使用。&lt;/p&gt;
&lt;p&gt;安装 Raycast 后，你可以轻松告别一堆单独的工具，比如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. Alfred（更别提 Raycast 免费！）
2. 剪切板管理工具
3. Snippets 代码片段管理工具
4. 软件卸载工具
5. 窗口管理工具
6. 便签工具
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Raycast 自带的 Store 拥有丰富的扩展，你可以根据需要找到最适合自己的工具。比如不少主流软件都提供了对应的 Raycast 插件。&lt;/p&gt;
&lt;p&gt;借助 Quicklink 功能，你可以自定义命令，比如输入 v2 回车，直接打开 V2EX 摸鱼，效率拉满。&lt;/p&gt;
&lt;p&gt;这里我推荐一些常用的扩展：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- Base64：快速解析/编码文本
- ChatGPT：一键召唤 GPT，随问随答
- Google Search：直接启动 Google 快速搜索
- Kill Process：快速查找并 kill 进程
- Microsoft Edge：搜索收藏夹与历史记录
- Search Commands：搜索并查看 Linux 命令
- Search HTTP Status Codes：查阅 HTTP 状态码的含义
- Search npm Packages：快速查找 npm 包信息
- Transform：文本/代码转换神器，例如 CSS 转 JavaScript Object
- Visual Studio Code：直接用 VSCode 打开项目文件
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;RIME&lt;/h4&gt;
&lt;p&gt;RIME（中文名“鼠须管”）是一款开源输入法，堪称「神级输入法」。如果你和我一样，想摆脱互联网公司输入法的捆绑，追求更高自由度的自定义配置，那么 RIME 是绝对值得一试的选择。&lt;/p&gt;
&lt;p&gt;推荐配置：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/iDvel/rime-ice&quot;&gt;rime-ice&lt;/a&gt;：基本开箱即用，功能强大。&lt;/li&gt;
&lt;li&gt;词库优化：rime-ice 自带的词库虽然够用，但不如搜狗全面，所以我找了个更强的&lt;a href=&quot;https://github.com/Iorest/rime-dict&quot;&gt;词库配置&lt;/a&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为什么选择 RIME？&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 高度自定义：支持任意配置方案，可以根据个人输入习惯深度定制。
2. 纯净无广告：没有后台上传数据的烦恼，输入体验干净纯粹。
3. 多平台支持：macOS（鼠须管）、Windows（小狼毫）、Linux（中州韵）都能用。
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Remote for Mac&lt;/h4&gt;
&lt;p&gt;可以在 iOS 上远程操作 macOS 的软件，有时候用它挺方便。&lt;/p&gt;
&lt;h4&gt;Scroll Reverser&lt;/h4&gt;
&lt;p&gt;让鼠标滚动方向变得更习惯，尤其是从 Windows 转到 macOS 的用户。&lt;/p&gt;
&lt;h4&gt;Skim&lt;/h4&gt;
&lt;p&gt;轻量级 PDF 阅读器，支持标注和笔记，非常适合看书或做研究。&lt;/p&gt;
&lt;h4&gt;Slack&lt;/h4&gt;
&lt;p&gt;团队协作的聊天工具，个人感觉比国内同类软件强太多。&lt;/p&gt;
&lt;h4&gt;Snipaste&lt;/h4&gt;
&lt;p&gt;从 Windows 开始就用的截图工具，简单快捷。&lt;/p&gt;
&lt;h4&gt;SnippetsLab&lt;/h4&gt;
&lt;p&gt;代码片段管理器，用于记录解决方案和命令行代码，配合 Raycast 非常高效。&lt;/p&gt;
&lt;h4&gt;Sourcetree&lt;/h4&gt;
&lt;p&gt;虽然更习惯用命令行管理 Git，但遇到复杂操作时，Sourcetree 简直是救星。&lt;/p&gt;
&lt;h4&gt;Spotify&lt;/h4&gt;
&lt;p&gt;用尼区车 42/年的价格听歌，值到飞起。&lt;/p&gt;
&lt;h4&gt;Tor Browser&lt;/h4&gt;
&lt;p&gt;你懂的。&lt;/p&gt;
&lt;p&gt;算了，可能还有人不太懂。简单来说，就是个“洋葱浏览器”，主要用来访问暗网。但别一听“暗网”就觉得都是些见不得光的东西，事实上，暗网里也有很多正常内容。比如之前 z-lib 网站被 FBI 封了，那段时间，只有通过暗网才能继续访问。所以，你懂了吧？&lt;/p&gt;
&lt;h4&gt;TinyPNG4Mac&lt;/h4&gt;
&lt;p&gt;一款用于压缩 PNG 图片的工具。可能你不知道，它还有 macOS 客户端。优点是没有文件上传大小的限制，只要申请一个 API key，就能畅快使用，非常方便。&lt;/p&gt;
&lt;h4&gt;Tuxera Disk Manager&lt;/h4&gt;
&lt;p&gt;解决 macOS 不支持 NTFS 格式的问题，可以随意读写 NTFS 硬盘。&lt;/p&gt;
&lt;h4&gt;Typora&lt;/h4&gt;
&lt;p&gt;Markdown 编辑器，正式版收费，但用习惯了感觉值得。&lt;/p&gt;
&lt;h4&gt;Velja&lt;/h4&gt;
&lt;p&gt;双浏览器策略神器，专为解决个人与工作环境混乱而生。你可以根据 URL、应用 或 快捷键 设置不同的默认浏览器，轻松实现账号和插件的彻底隔离。&lt;/p&gt;
&lt;p&gt;浏览器里个人和工作账号分不清楚，一直是个让我头疼的问题，尤其是不同的密码管理器插件在同一浏览器中互相冲突，简直让人崩溃。后来我果断采用了双浏览器策略，分别处理工作和个人的账号与插件。&lt;/p&gt;
&lt;p&gt;Velja 正是这类需求的完美解决方案：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1. 匹配特定 URL：比如打开公司内部网站，就自动跳转到工作浏览器。
2. 快捷键支持：用 Fn 键加点击，就能快速选择浏览器。
3. 按应用区分：在 Slack 或邮件客户端中点击链接，也能智能地选择对应浏览器。
4. 浏览器插件：选择另外一个浏览器打开当前网页。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;简而言之，所有工作相关的内容都会被引导到指定的浏览器，从此告别账号混乱，工作和个人完美隔离。&lt;/p&gt;
&lt;h4&gt;Visual Studio Code&lt;/h4&gt;
&lt;p&gt;无需介绍的代码编辑器。&lt;/p&gt;
&lt;h4&gt;Windows App&lt;/h4&gt;
&lt;p&gt;macOS 远程连接 RDP 桌面的工具，远程办公必备。&lt;/p&gt;
&lt;h4&gt;Zen Browser&lt;/h4&gt;
&lt;p&gt;今年朋友推荐了 Zen 浏览器，它流畅的运行速度和便捷的工作区功能尤其适合打开工作相关的网站。&lt;/p&gt;
&lt;p&gt;因此它很自然地成为了我「双浏览器策略」中的选择之一。&lt;/p&gt;
&lt;h2&gt;iOS 软件&lt;/h2&gt;
&lt;h4&gt;Alook&lt;/h4&gt;
&lt;p&gt;一块钱的 iOS 浏览器，却拥有强大的功能！无推送、无新闻、无广告，内置播放器还能悬浮播放网页视频，支持倍速和后台播放。强烈推荐！&lt;/p&gt;
&lt;h4&gt;Authenticator&lt;/h4&gt;
&lt;p&gt;2FA 验证码管理器，保护你的账户安全。微软、谷歌、LastPass 等都有类似的 APP，我用的是 LastPass 的，功能都差不多，选择你喜欢的就好。&lt;/p&gt;
&lt;h4&gt;Bark&lt;/h4&gt;
&lt;p&gt;让你可以把自定义消息推送到手机上的神器，M 系列 Mac 上也能用，玩起来很有意思。&lt;/p&gt;
&lt;h4&gt;彩云天气&lt;/h4&gt;
&lt;p&gt;最强天气预报 APP，当年送终身 VIP，但后来加了个 SSVIP 和 AI 小助手。虽说改版了，但天气数据和推送还是很靠谱。&lt;/p&gt;
&lt;h4&gt;Cape&lt;/h4&gt;
&lt;p&gt;用屏幕使用时间隐藏 APP，实用且隐秘。&lt;/p&gt;
&lt;h4&gt;锤子便签&lt;/h4&gt;
&lt;p&gt;老罗的经典产品，2024 年依然香，操作流畅，用着舒服。&lt;/p&gt;
&lt;h4&gt;DAMA&lt;/h4&gt;
&lt;p&gt;聊天打码必备，一键隐私保护，八爷的独立开发作品，简单又实用。&lt;/p&gt;
&lt;h4&gt;FIMO&lt;/h4&gt;
&lt;p&gt;复古滤镜相机，随便一拍就是大片。但如果像我一样没审美，效果可能也就那样。&lt;/p&gt;
&lt;h4&gt;HTTP Catcher&lt;/h4&gt;
&lt;p&gt;抓包工具，用来劫持请求、修改响应，研究技术或者调试时很好用。&lt;/p&gt;
&lt;h4&gt;Shortcuts&lt;/h4&gt;
&lt;p&gt;iOS 的效率天花板，用来自动化各种操作，解锁后你会发现手机更强了。&lt;/p&gt;
&lt;h4&gt;谜底时钟&lt;/h4&gt;
&lt;p&gt;充电时仪式感满满的时钟，设计感超棒，买了终身版没后悔。&lt;/p&gt;
&lt;h4&gt;nplayer&lt;/h4&gt;
&lt;p&gt;最强播放器，局域网远程播放、全格式支持，无需转码，无广告，一步到位。&lt;/p&gt;
&lt;h4&gt;Open Scanner&lt;/h4&gt;
&lt;p&gt;免费的文档扫描 APP，效果杠杠的，在 X 上被安利后就没换过。&lt;/p&gt;
&lt;h4&gt;Photo Cleaner&lt;/h4&gt;
&lt;p&gt;清理大图片、相似照片的利器，清理空间的好帮手。&lt;/p&gt;
&lt;h4&gt;Photomator&lt;/h4&gt;
&lt;p&gt;系统相册不够用？换它，替代效果一流，还支持 macOS。&lt;/p&gt;
&lt;h4&gt;Picsew&lt;/h4&gt;
&lt;p&gt;长截图神器，效果不错，操作方便。&lt;/p&gt;
&lt;h4&gt;PiP&lt;/h4&gt;
&lt;p&gt;让 Youtube 视频后台画中画播放（虽然 Alook 也能实现，但多一个选择总归好）。&lt;/p&gt;
&lt;h5&gt;Quantumult X&lt;/h5&gt;
&lt;p&gt;比小火箭省电还强大，今年买的最值的一款 APP，完全改变了我的使用习惯。&lt;/p&gt;
&lt;h4&gt;Reeder&lt;/h4&gt;
&lt;p&gt;看 RSS 的丝滑神器，手机端、电脑端都顶用，做 RSS 用户的福音。&lt;/p&gt;
&lt;h4&gt;什么时辰&lt;/h4&gt;
&lt;p&gt;查看时辰和放小组件的 APP，传统文化爱好者的心头好。&lt;/p&gt;
&lt;h4&gt;什么值得买&lt;/h4&gt;
&lt;p&gt;这个不用我多说了，剁手指南。&lt;/p&gt;
&lt;h4&gt;Stream&lt;/h4&gt;
&lt;p&gt;又一个抓包工具，简单粗暴。&lt;/p&gt;
&lt;h4&gt;Termius&lt;/h4&gt;
&lt;p&gt;功能全面的 SSH 客户端，远程管理必备。&lt;/p&gt;
&lt;h4&gt;Traffic Rider&lt;/h4&gt;
&lt;p&gt;无聊时解压的摩托车游戏，开一把放松心情。&lt;/p&gt;
&lt;h4&gt;Tubecasts&lt;/h4&gt;
&lt;p&gt;后台听 Youtube 的神器，支持倍速播放和定时关闭，睡觉前听视频专用。&lt;/p&gt;
&lt;h4&gt;VLLO&lt;/h4&gt;
&lt;p&gt;视频剪辑工具，操作简单，轻量用户的好帮手。&lt;/p&gt;
&lt;h4&gt;伟途亦可思&lt;/h4&gt;
&lt;p&gt;V2EX 的第三方客户端，摸鱼一绝。&lt;/p&gt;
&lt;h4&gt;小睡眠&lt;/h4&gt;
&lt;p&gt;助眠神器，睡觉听点白噪音或者轻音乐，秒入梦乡。&lt;/p&gt;
&lt;h4&gt;熊猫吃短信&lt;/h4&gt;
&lt;p&gt;超好用的短信过滤工具，熊猫 1 表现很稳，据说熊猫 2 改成订阅制后褒贬不一，所以我一直留在 1。&lt;/p&gt;
&lt;h2&gt;安卓 TV 软件&lt;/h2&gt;
&lt;h4&gt;BBLL&lt;/h4&gt;
&lt;p&gt;第三方 bilibili 客户端，支持弹幕，界面清爽，个人感觉比官方版更适合 TV。&lt;/p&gt;
&lt;h4&gt;Emby&lt;/h4&gt;
&lt;p&gt;Emby 客户端的破解版，用来看 emby 服的视频，流畅好用。&lt;/p&gt;
&lt;h4&gt;Simple Live TV&lt;/h4&gt;
&lt;p&gt;专注看网络直播，涵盖各大网络平台的直播源，小巧实用。&lt;/p&gt;
&lt;h4&gt;Smartube&lt;/h4&gt;
&lt;p&gt;无广告的第三方 YouTube 客户端，体验比官方版还丝滑。&lt;/p&gt;
&lt;h4&gt;tvbox&lt;/h4&gt;
&lt;p&gt;必备的看剧神器，开源且可定制，各种魔改版本应有尽有。&lt;/p&gt;
&lt;p&gt;推荐版本：&lt;a href=&quot;https://github.com/takagen99/Box&quot;&gt;takagen99&lt;/a&gt;，界面美观，使用舒适。&lt;/p&gt;
&lt;p&gt;配置地址：http://www.饭太硬.top/tv/，配置完直接开爽。&lt;/p&gt;
&lt;h4&gt;天光云影&lt;/h4&gt;
&lt;p&gt;看直播源的软件，支持自定义源，直播体验优秀，操作方便。&lt;/p&gt;
&lt;h4&gt;一起看 TV&lt;/h4&gt;
&lt;p&gt;看剧专用，资源丰富、界面简洁，适合电视上轻松追剧。&lt;/p&gt;
&lt;h2&gt;网站&lt;/h2&gt;
&lt;p&gt;以下是一些实用的网站，配合 Raycast 的 quicklink 使用可以大幅提升效率。&lt;/p&gt;
&lt;h4&gt;aistudio.google.com&lt;/h4&gt;
&lt;p&gt;Google 的 AI Studio，探索和测试 AI 模型的平台，可以免费用最新的模型。&lt;/p&gt;
&lt;h4&gt;aktv.top&lt;/h4&gt;
&lt;p&gt;提供稳定的港澳台直播源。&lt;/p&gt;
&lt;h4&gt;bilin.ai&lt;/h4&gt;
&lt;p&gt;一个 AI 生成工具平台，用于图片、文本和代码生成。&lt;/p&gt;
&lt;h4&gt;chatgpt.com&lt;/h4&gt;
&lt;p&gt;开了 ChatGPT Plus 后一直在用，生产力提升神器。&lt;/p&gt;
&lt;h4&gt;copilot.microsoft.com&lt;/h4&gt;
&lt;p&gt;访问 Microsoft Copilot 的专属网站，体验 AI 助理的强大功能。&lt;/p&gt;
&lt;h4&gt;cs.github.com&lt;/h4&gt;
&lt;p&gt;快速从 GitHub 仓库中搜索代码，非常方便。&lt;/p&gt;
&lt;h4&gt;gemini.google.com&lt;/h4&gt;
&lt;p&gt;Google 的 AI Gemini 项目入口，专注于生成式 AI 的未来探索。&lt;/p&gt;
&lt;p&gt;如果可以用 aistudio.google.com，就不需要这个了。&lt;/p&gt;
&lt;h4&gt;learning.google.com&lt;/h4&gt;
&lt;p&gt;Google 的在线学习平台，涵盖多种技术和技能的课程。&lt;/p&gt;
&lt;h4&gt;notebooklm.google.com&lt;/h4&gt;
&lt;p&gt;Google 推出的 AI 学习工具，用来整理和学习内容特别好用。&lt;/p&gt;
&lt;h4&gt;npm.runkit.com&lt;/h4&gt;
&lt;p&gt;在线运行 npm 包的沙盒环境，用于测试和演示代码。&lt;/p&gt;
&lt;h4&gt;npmgraph.js.org&lt;/h4&gt;
&lt;p&gt;用图表方式可视化 npm 包的依赖关系。&lt;/p&gt;
&lt;h4&gt;npmtrends.com&lt;/h4&gt;
&lt;p&gt;比较多个 npm 包的下载趋势和受欢迎程度。&lt;/p&gt;
&lt;h4&gt;perplexity.ai&lt;/h4&gt;
&lt;p&gt;一款结合搜索引擎和问答功能的 AI 工具。&lt;/p&gt;
&lt;h4&gt;pkg-graph.info&lt;/h4&gt;
&lt;p&gt;另一个 npm 包依赖可视化工具，帮助理解包的依赖结构。&lt;/p&gt;
&lt;h4&gt;promptperfect.jina.ai&lt;/h4&gt;
&lt;p&gt;AI 提示优化工具，生成和测试最佳的 AI 提示。&lt;/p&gt;
&lt;h4&gt;phind.com&lt;/h4&gt;
&lt;p&gt;专注于开发者的搜索引擎，直接返回技术相关的搜索结果。&lt;/p&gt;
&lt;h4&gt;search.luxirty.com&lt;/h4&gt;
&lt;p&gt;基于 Google 的搜索引擎，屏蔽内容农场，没有广告和跟踪，搜索体验清爽快速。&lt;/p&gt;
&lt;h4&gt;search.saveweb.org&lt;/h4&gt;
&lt;p&gt;搜索互联网存档，找回那些已经被删掉的网页内容。&lt;/p&gt;
&lt;h4&gt;thinkany.ai&lt;/h4&gt;
&lt;p&gt;AI 相关工具集合，可以探索不同场景下的 AI 应用。&lt;/p&gt;
&lt;h4&gt;v0.dev&lt;/h4&gt;
&lt;p&gt;一个实验性开发工具平台。&lt;/p&gt;
&lt;h4&gt;x.ai&lt;/h4&gt;
&lt;p&gt;grok AI 应用，现在面向所有用户免费开放。&lt;/p&gt;
&lt;h2&gt;浏览器扩展&lt;/h2&gt;
&lt;h4&gt;AutoScroll&lt;/h4&gt;
&lt;p&gt;让 macOS 上也能用鼠标中键自动滚动网页，对 Windows 用户非常友好。&lt;/p&gt;
&lt;h4&gt;Bypass Paywalls Clean&lt;/h4&gt;
&lt;p&gt;绕过各种网站的付费墙，轻松访问隐藏内容。&lt;/p&gt;
&lt;h4&gt;Console Importer&lt;/h4&gt;
&lt;p&gt;在浏览器控制台一键 import npm 包，调试效率直线上升。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$i(&quot;jquery&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Don’t track me Google&lt;/h4&gt;
&lt;p&gt;屏蔽 Google 的隐私追踪，保护你的数据安全。&lt;/p&gt;
&lt;h4&gt;JSON Viewer&lt;/h4&gt;
&lt;p&gt;最强 JSON 查看器，格式化、折叠、查询一气呵成，开发者必备。&lt;/p&gt;
&lt;h4&gt;Kimi 浏览器助手&lt;/h4&gt;
&lt;p&gt;总结文章的好帮手，而且完全免费。&lt;/p&gt;
&lt;h4&gt;OneTab&lt;/h4&gt;
&lt;p&gt;一键保存所有标签页，整理工作或学习内容神器。&lt;/p&gt;
&lt;h4&gt;Save to Notion&lt;/h4&gt;
&lt;p&gt;一键把网页内容保存到 Notion，信息管理更高效。&lt;/p&gt;
&lt;h4&gt;SimpleExtManager&lt;/h4&gt;
&lt;p&gt;快速管理浏览器扩展，随用随开，不浪费资源。&lt;/p&gt;
&lt;h4&gt;SponsorBlock for YouTube&lt;/h4&gt;
&lt;p&gt;跳过 YouTube 视频的赞助片段，观看体验直接升级。&lt;/p&gt;
&lt;h4&gt;Stream Recorder&lt;/h4&gt;
&lt;p&gt;支持下载 HLS / m3u8 格式的直播视频，非常好用。&lt;/p&gt;
&lt;h4&gt;Velja&lt;/h4&gt;
&lt;p&gt;想用另一个浏览器打开当前网页？一键搞定。&lt;/p&gt;
&lt;h4&gt;Video Speed Controller&lt;/h4&gt;
&lt;p&gt;随意调节视频播放速度，倍速党福音。&lt;/p&gt;
&lt;h4&gt;Wayback Machine&lt;/h4&gt;
&lt;p&gt;快速保存网页到互联网档案馆，还能查看过去的版本。&lt;/p&gt;
&lt;h4&gt;uBlacklist&lt;/h4&gt;
&lt;p&gt;屏蔽 Google 搜索结果中的指定网站，专治乱七八糟的内容。&lt;/p&gt;
&lt;h4&gt;uBlock Origin&lt;/h4&gt;
&lt;p&gt;高效移除广告和跟踪器，浏览器清爽到飞起。&lt;/p&gt;
&lt;h4&gt;v2ex plus&lt;/h4&gt;
&lt;p&gt;优化 V2EX 的使用体验，增加各种实用小功能。&lt;/p&gt;
&lt;h4&gt;沉浸式翻译&lt;/h4&gt;
&lt;p&gt;深度翻译工具，强烈建议 Linux 用户配合 DeeplX API key 使用。&lt;/p&gt;
&lt;h4&gt;猫抓&lt;/h4&gt;
&lt;p&gt;网页上的媒体嗅探神器，各种音视频资源轻松抓取。&lt;/p&gt;
&lt;h4&gt;篡改猴&lt;/h4&gt;
&lt;p&gt;油猴脚本管理工具，扩展浏览器功能的不二之选。&lt;/p&gt;
&lt;h4&gt;过滤广告 為 Youtube™&lt;/h4&gt;
&lt;p&gt;纯粹为 YouTube 服务的广告屏蔽工具，专注且好用。&lt;/p&gt;
&lt;h4&gt;隐私獾&lt;/h4&gt;
&lt;p&gt;屏蔽广告和追踪器，还能让你手动选择是否加载一些网页组件。&lt;/p&gt;
&lt;h2&gt;命令行工具&lt;/h2&gt;
&lt;p&gt;我会在这里分享一些我常用的命令行工具和自己写的 alias，希望能提升你的终端使用效率。&lt;/p&gt;
&lt;h4&gt;ag&lt;/h4&gt;
&lt;p&gt;更好的 grep。&lt;/p&gt;
&lt;h4&gt;brs&lt;/h4&gt;
&lt;p&gt;配合 fzf 实现一键切换分支：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brs() {
 gbr | fzf-tmux | awk &apos;{print $1}&apos; | sed &apos;s|origin/||&apos; | xargs -I {} git switch {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;btop&lt;/h4&gt;
&lt;p&gt;更好的 top。&lt;/p&gt;
&lt;h4&gt;bat&lt;/h4&gt;
&lt;p&gt;更好的 cat&lt;/p&gt;
&lt;h4&gt;bclm&lt;/h4&gt;
&lt;p&gt;有个软件叫 AlDente，它可以帮你调整电池充电阈值。简单来说，当你的电池电量达到设定的数值后，软件会自动停止充电并开始使用电池供电。这样一来，你就不用手动插拔 MacBook 的充电器了。&lt;/p&gt;
&lt;p&gt;这款软件是收费的，而 bclm 则是一个开源的命令行工具，可以达到和 AlDente 一样的效果。&lt;/p&gt;
&lt;p&gt;使用它后，我的电池健康度一年才降了 5%！不过自从升级 Sequoia 后 macOS 不给写入 SMC 的权限了，所以 bclm 行不通了，现在使用 &lt;a href=&quot;https://github.com/lslqtz/bclm_loop/tree/main&quot;&gt;bclm_loop&lt;/a&gt; 代替。&lt;/p&gt;
&lt;h4&gt;bkp&lt;/h4&gt;
&lt;p&gt;快速备份文件&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bkp() {
  cp -Rp &quot;$1&quot;{,.bak}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;c&lt;/h4&gt;
&lt;p&gt;clear 的别名，更快清除屏幕。&lt;/p&gt;
&lt;h4&gt;cpf&lt;/h4&gt;
&lt;p&gt;我们都知道 pbcopy 可以复制输出的内容，比如 &lt;code&gt;cat 1.txt | pbcopy&lt;/code&gt;。但有时候我们想要复制文件本身，而不是文件内容。这种情况下，通常需要先运行 &lt;code&gt;open .&lt;/code&gt; 命令打开当前目录，然后手动拷贝文件。其实可以用纯命令行的方式来操作：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cpf() {
  osascript \
    -e &apos;on run args&apos; \
    -e &apos;set the clipboard to POSIX file (first item of args)&apos; \
    -e end \
    &quot;$(pwd)/$@&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样以后我们通过 &lt;code&gt;cpf 1.txt&lt;/code&gt; 就能直接在微信等软件中粘贴并发送这个文件了。&lt;/p&gt;
&lt;h4&gt;difft&lt;/h4&gt;
&lt;p&gt;理解语法的 diff 工具，可配合 git 或者 hg 使用，让 diff 更好看。&lt;/p&gt;
&lt;p&gt;可查看 git 小节查看使用例子。&lt;/p&gt;
&lt;h4&gt;edge-tts&lt;/h4&gt;
&lt;p&gt;顾名思义，一个使用 Microsoft Edge 文本转声音的工具，配合 Raycast script 可实现快速阅读一段文本，强烈推荐 xiaoxiao 人声。&lt;/p&gt;
&lt;h4&gt;fd&lt;/h4&gt;
&lt;p&gt;更好的 find。&lt;/p&gt;
&lt;h4&gt;fonttools&lt;/h4&gt;
&lt;p&gt;一个字体工具，用于生成字体子集或优化文件大小，从而达到瘦身效果，对于前端来说非常好用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pyftsubset OPPOSans-H.ttf --text=$(cat 仅需要的文本.txt | rg -e &apos;[\w\d]&apos; -oN --no-filename|sort|uniq|tr -d &apos;\n&apos;) --no-hinting
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;fuck&lt;/h4&gt;
&lt;p&gt;暴躁老哥的命令行工具，当你输错命令的时候，fuck 一下可帮你纠正。&lt;/p&gt;
&lt;h4&gt;fs&lt;/h4&gt;
&lt;p&gt;其实是 &lt;a href=&quot;https://github.com/sxyazi/yazi&quot;&gt;yazi&lt;/a&gt; 的别名，一个很好用的终端文件管理器。&lt;/p&gt;
&lt;h4&gt;fzf&lt;/h4&gt;
&lt;p&gt;如果你喜欢命令行操作，那么一定要了解这个工具！它可以让你对所有输出进行模糊搜索和自定义操作。&lt;/p&gt;
&lt;p&gt;分享一下我的默认设置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 默认选项
export FZF_DEFAULT_OPTS=&quot;--height 100% \
--layout reverse \
--prompt &apos;∷ &apos; \
--pointer ▶ \
--marker ⇒&quot;

# 这行配置开启 ag 查找隐藏文件 及忽略 .git 文件
export FZF_DEFAULT_COMMAND=&apos;ag --hidden --ignore .git --ignore .hg -l -g &quot;&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;fzf 在 tmux 下有个很酷的功能，可以让 tab 补全选项列表通过弹出窗体展示，且窗体会更紧凑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if [[ -n &quot;$TMUX&quot; ]]; then
  zstyle &apos;:fzf-tab:*&apos; fzf-command ftb-tmux-popup
fi
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此外，还可以使用 &lt;code&gt;command **&lt;/code&gt; + tab 键的组合来快速预览根目录（例如在执行 &lt;code&gt;cd **&lt;/code&gt; 时）或列出各个指令介绍（例如在执行 &lt;code&gt;git **&lt;/code&gt; 时）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_fzf_complete_git() {
  _fzf_complete --bind &quot;enter:become(echo {} | awk &apos;{print \$1}&apos;)&quot; -- &quot;$@&quot; &amp;lt; &amp;lt;(
    git --help -a | grep -E &apos;^\s+&apos; | awk &apos;{print $1 &quot;       -- &quot; $2, $3, $4, $5}&apos;
  )
}

_fzf_comprun() {
  local command=$1
  shift

  case &quot;$command&quot; in
    tree)         find . -type d | fzf --preview &apos;tree -C {} -I node_modules&apos; &quot;$@&quot;;;
    cd)         find . -type d | fzf --preview &apos;tree -C {} -I node_modules&apos; &quot;$@&quot;;;
    *)            fzf &quot;$@&quot; ;;
  esac
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你还可以在这个本文里看到很多我使用 fzf 配合其它工具的例子。&lt;/p&gt;
&lt;h4&gt;git&lt;/h4&gt;
&lt;p&gt;应该没人不知道了，但你可能不知道，如果你使用的 zsh，那它提供了一系列的 git 的别名：&lt;a href=&quot;https://kapeli.com/cheat_sheets/Oh-My-Zsh_Git.docset/Contents/Resources/Documents/index&quot;&gt;Oh-My-Zsh Git&lt;/a&gt;，这里还有一些我与 fzf、difft 等工具配合使用的例子：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 更好的 git diff
gd() {
  GIT_EXTERNAL_DIFF=difft git diff
}

# 更好的 git show
gsh() {
  GIT_PAGER_IN_USE=1 GIT_EXTERNAL_DIFF=difft git show &quot;$1&quot; --ext-diff | less
}

ggfzf() { fzf --no-sort --ansi --bind &quot;enter:execute(echo {} | sed &apos;s/\x1b\[[0-9;]*m//g&apos; | cut -d&apos; &apos; -f2 | xargs -I {} bash -c \&quot;source ~/.bash_profile &amp;amp;&amp;amp; gd {}\&quot;)&quot;; }

# git 提交列表，按回车可快速查看提交信息
gglg() {
  git log --color --graph --pretty=format:&apos;%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)&amp;lt;%an&amp;gt;%Creset&apos; --abbrev-commit | ggfzf
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;h&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;history | fzf&lt;/code&gt; 的别名。&lt;/p&gt;
&lt;h4&gt;hg&lt;/h4&gt;
&lt;p&gt;与 git 差不多的版本控制工具，主要我司内部使用这个工具。&lt;/p&gt;
&lt;p&gt;虽然我自己也有一套相关的 alias，但由于这个工具用户不多，分享的意义不大，就先不列出来了。&lt;/p&gt;
&lt;h4&gt;htop&lt;/h4&gt;
&lt;p&gt;也是更好的 top。&lt;/p&gt;
&lt;h4&gt;ii&lt;/h4&gt;
&lt;p&gt;查看当前系统的一些信息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ii() {
  echo -e &quot;\n您已登录 ${RED}$HOST&quot;
  echo -e &quot;\n附加信息:$NC &quot;
  uname -a
  echo -e &quot;\n${RED}Users logged on:$NC &quot;
  w -h
  echo -e &quot;\n${RED}Current date :$NC &quot;
  date
  echo -e &quot;\n${RED}Machine stats :$NC &quot;
  uptime
  echo -e &quot;\n${RED}Memory stats :$NC &quot;
  free
  echo -e &quot;\n${RED}Diskspace :$NC &quot;
  df -h
  echo -e &quot;\n${RED}Local IP Address :$NC&quot;
  myip
  echo
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;jq&lt;/h4&gt;
&lt;p&gt;对 json 文件进行操作、过滤等。&lt;/p&gt;
&lt;h4&gt;jnv&lt;/h4&gt;
&lt;p&gt;交互式的 jq。&lt;/p&gt;
&lt;h4&gt;lsd&lt;/h4&gt;
&lt;p&gt;更好的 ls。&lt;/p&gt;
&lt;h4&gt;ns&lt;/h4&gt;
&lt;p&gt;快速打开一个临时目录，做任何实践性的操作。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ns() {
  newdir=`date +%s`
  fulldir=~/Temp/$newdir
  mkdir -p $fulldir
  cd $fulldir
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;nvm&lt;/h4&gt;
&lt;p&gt;一开始我用的就是这个老牌的 Node.js 版本管理工具，但后来发现它让终端启动太慢，实在受不了，于是换成了 n。可惜在工作中又要求统一用 nvm，只好又换回去了。对于终端启动慢的问题，可以通过冷启动来解决。现在用下来还是能接受的。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ~/.zshrc
export NVM_DIR=&quot;$HOME/.nvm&quot;
[ -s &quot;$NVM_DIR/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;$NVM_DIR/nvm.sh&quot;  # This loads nvm
[ -s &quot;$NVM_DIR/bash_completion&quot; ] &amp;amp;&amp;amp; \. &quot;$NVM_DIR/bash_completion&quot;  # This loads nvm bash_completion

# install zsh-async if it’s not present
if [[ ! -a ~/.zsh-async ]]; then
  git clone git@github.com:mafredri/zsh-async.git ~/.zsh-async
fi
source ~/.zsh-async/async.zsh

load_nvmrc() {
  local nvmrc_path
  nvmrc_path=&quot;$(nvm_find_nvmrc)&quot;

  if [ -n &quot;$nvmrc_path&quot; ]; then
    local nvmrc_node_version
    nvmrc_node_version=$(nvm version &quot;$(cat &quot;${nvmrc_path}&quot;)&quot;)

    if [ &quot;$nvmrc_node_version&quot; = &quot;N/A&quot; ]; then
      nvm install
    elif [ &quot;$nvmrc_node_version&quot; != &quot;$(nvm version)&quot; ]; then
      nvm use &amp;gt; /dev/null
    fi
  elif [ -n &quot;$(PWD=$OLDPWD nvm_find_nvmrc)&quot; ] &amp;amp;&amp;amp; [ &quot;$(nvm version)&quot; != &quot;$(nvm version default)&quot; ]; then
    nvm use default &amp;gt; /dev/null
  fi
}

# async_loader
function async_loader() {
  # nvm
  export NVM_DIR=&quot;$([ -z &quot;${XDG_CONFIG_HOME-}&quot; ] &amp;amp;&amp;amp; printf %s &quot;${HOME}/.nvm&quot; || printf %s &quot;${XDG_CONFIG_HOME}/nvm&quot;)&quot;
  [ -s &quot;$NVM_DIR/nvm.sh&quot; ] &amp;amp;&amp;amp; \. &quot;$NVM_DIR/nvm.sh&quot;
  load_nvmrc
}

# init worker
async_start_worker async_load_worker -n
async_register_callback async_load_worker async_loader
async_job async_load_worker sleep 0.1

# 改变目录时自动切换 Node.js 版本
add-zsh-hook chpwd load_nvmrc
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;mdcat&lt;/h4&gt;
&lt;p&gt;更好地渲染 markdown 的 cat 命令，支持高亮 markdown 语法、代码块、图片等。&lt;/p&gt;
&lt;h4&gt;ppwd&lt;/h4&gt;
&lt;p&gt;快速复制当前路径：&lt;code&gt;alias ppwd=pwd | pbcopy&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;projclean&lt;/h4&gt;
&lt;p&gt;项目依赖关系和构建工件清理工具，支持大部分项目。&lt;/p&gt;
&lt;p&gt;对于前端来说，它可以帮你一键删除所有项目中的 node_modules 目录，从而释放硬盘空间。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cargo install projclean

# 谨慎操作！！！
projclean node_modules
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;r&lt;/h4&gt;
&lt;p&gt;快速切换到当前仓库的根目录。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;r() {
  if is_git; then
    cd &quot;$(git rev-parse --show-toplevel)&quot; || return
  elif is_hg; then
    cd &quot;$(hg root)&quot; || return
  else
    cd ~ || return
  fi
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;rsync&lt;/h4&gt;
&lt;p&gt;rsync 是 linux 系统下的数据镜像备份工具。使用快速增量备份工具。&lt;/p&gt;
&lt;h4&gt;s&lt;/h4&gt;
&lt;p&gt;快速应用 &lt;code&gt;.zshrc&lt;/code&gt; 等文件而无需重启终端，&lt;code&gt;source ~/.zshrc&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;server&lt;/h4&gt;
&lt;p&gt;快速打开一个 http 服务器：&lt;code&gt;python3 -m http.server 8181&lt;/code&gt;。&lt;/p&gt;
&lt;h4&gt;shellcheck&lt;/h4&gt;
&lt;p&gt;顾名思义，检查 shell 脚本用的，类似于 eslint 之于 JavaScript。&lt;/p&gt;
&lt;h4&gt;tldr&lt;/h4&gt;
&lt;p&gt;查询某个命令的使用示例。&lt;/p&gt;
&lt;h4&gt;tree&lt;/h4&gt;
&lt;p&gt;输出当前树形目录结构。&lt;/p&gt;
&lt;h4&gt;trash&lt;/h4&gt;
&lt;p&gt;代替 rm，不会直接删除，而是把文件或目录移动到回收站，让你有后悔药吃，可配合 Raycast 的 Empty Trash 指令快速清空回收站。&lt;/p&gt;
&lt;h4&gt;upp&lt;/h4&gt;
&lt;p&gt;一键通过 vim 编写提交信息，并进行推送。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git add -A
temp_file=$(mktemp)
vim +startinsert &quot;$temp_file&quot;
commit_message=$(cat &quot;$temp_file&quot;)

if [ -z &quot;$commit_message&quot; ]; then
  echo &quot;Commit message was empty. Aborting.&quot;
  rm -f &quot;$temp_file&quot;
  return
fi

echo -n &quot;Commit message:&quot;
echo &quot; $commit_message&quot;

echo -n &quot;Do you want to push? (y/N): &quot;

read confirm

git commit -m &quot;$commit_message&quot;

[ $? -eq 1 ] &amp;amp;&amp;amp; exit 1

if [[ &quot;$confirm&quot; =~ ^[Yy]$ ]]; then
  git push
fi

rm -f &quot;$temp_file&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;vo&lt;/h4&gt;
&lt;p&gt;使用 fzf 模糊搜索文件并通过 vim 打开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vo() {
  selected_file=$(_fzf)
  if [ $? -eq 0 ]; then
    nvim &quot;$selected_file&quot;
  fi
}

以此类推可以实现快速通过其它工具打开。
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;wx&lt;/h4&gt;
&lt;p&gt;快速通过微信开发者工具当前项目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wx() {
  /Applications/wechatwebdevtools.app/Contents/MacOS/cli open --project &quot;$(pwd)/$1&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;yt-dlp&lt;/h4&gt;
&lt;p&gt;youtube-dl 已死，这是它的代替方案。&lt;/p&gt;
&lt;h4&gt;z&lt;/h4&gt;
&lt;p&gt;我以前一直用 z.lua，直到我发现了 &lt;a href=&quot;https://github.com/ajeetdsouza/zoxide&quot;&gt;zoxide&lt;/a&gt;。我觉得这是一个更好的快速跳转目录工具。&lt;/p&gt;
&lt;p&gt;而且它还支持使用 &lt;code&gt;zi&lt;/code&gt; 命令列出候选目录，然后你可以自己选择要跳转的目录。&lt;/p&gt;
&lt;h4&gt;zsh&lt;/h4&gt;
&lt;p&gt;这几年我一直用 zsh，试过一段时间 fish，但发现不太习惯，而且不兼容我的 bash 脚本，所以还是回到了 zsh。目前我在用 &lt;a href=&quot;https://github.com/romkatv/powerlevel10k&quot;&gt;p10k&lt;/a&gt; 主题，挺不错的！&lt;/p&gt;
&lt;p&gt;插件主要是这些：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugins=(git mercurial-prompt mercurial zsh-autosuggestions fzf-tab zsh-vi-mode pnpm-shell-completion zsh-history-substring-search zsh-syntax-highlighting)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;npm 包&lt;/h2&gt;
&lt;p&gt;这里列出一些可能你不知道的 npm 包，它们能帮你提升开发效率。&lt;/p&gt;
&lt;h4&gt;c8&lt;/h4&gt;
&lt;p&gt;生成代码覆盖率报告的神器，基于 Node.js 原生功能，简单好用。&lt;/p&gt;
&lt;h4&gt;cli-highlight&lt;/h4&gt;
&lt;p&gt;让终端输出的代码瞬间“高亮”，看日志的快乐你懂的。&lt;/p&gt;
&lt;h4&gt;fast-glob&lt;/h4&gt;
&lt;p&gt;速度飞快的文件匹配工具，性能吊打 glob，但只能通过代码调用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const fg = require(&quot;fast-glob&quot;);
const entries = await fg([&quot;.editorconfig&quot;, &quot;**/index.js&quot;], { dot: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;glob&lt;/h4&gt;
&lt;p&gt;经典的文件匹配工具，写脚本必备，还支持直接在命令行里用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;glob &apos;src/**/*.sh&apos; | xargs shellcheck
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;jscpd&lt;/h4&gt;
&lt;p&gt;专治代码“复读机”，用来检测代码重复率，保质保量：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;jscpd -p &quot;./**/*.js&quot; -k 15 -l 5
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;ni / nr&lt;/h4&gt;
&lt;p&gt;自动匹配项目用的包管理器，ni 安装依赖，nr 快速跑脚本，效率拉满。&lt;/p&gt;
&lt;h4&gt;nolyfill&lt;/h4&gt;
&lt;p&gt;专治“历史包袱”的工具，替换掉那些为兼容老 Node.js 而存在的多余 polyfill，立减 node_modules 大小 50MiB 以上。&lt;/p&gt;
&lt;h4&gt;npm-run-all&lt;/h4&gt;
&lt;p&gt;并发或串行跑 npm scripts，简单又高效。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm-run-all --parallel lint:*&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;npm-check-updates&lt;/h4&gt;
&lt;p&gt;批量更新依赖版本，想升就升，谁还手动改 package.json。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ncu -i -t latest --packageFile &quot;generators/**/_package.json&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;minimist&lt;/h4&gt;
&lt;p&gt;Node.js 脚本写多了少不了它，轻松解析命令行参数。&lt;/p&gt;
&lt;h4&gt;typedoc&lt;/h4&gt;
&lt;p&gt;用 jsdoc 写 TypeScript 注释，生成文档专用。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm typedoc --options typedoc.json
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;vue-docgen&lt;/h4&gt;
&lt;p&gt;需要给 Vue 组件生成组件文档？用它，省时省力不糊涂。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;以上就是我 2024 年常用的工具，希望能对你有所帮助。当然，每个人的需求和喜好不同，适合我的不一定适合你。如果你有其他好用的工具推荐，欢迎在评论区分享！你最常用的工具是什么？你有什么提高效率的技巧？一起来讨论吧！&lt;/p&gt;
</content:encoded></item><item><title>对 RSS 的再思考：被忽视的力量</title><link>https://4ark.me/posts/2024-10-19-recent-thoughts-on-rss/</link><guid isPermaLink="true">https://4ark.me/posts/2024-10-19-recent-thoughts-on-rss/</guid><description>一位曾经的 RSS 重度用户对 RSS 的重新审视</description><pubDate>Sat, 19 Oct 2024 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;引言&lt;/h2&gt;
&lt;p&gt;作为一名多年的 RSS 重度用户，我曾通过 RSS 订阅几乎所有你能想到的内容：技术博客、社区帖子、Twitter（现称为 X）的动态、Telegram 消息、YouTube 更新等。得益于开源项目 RSSHub，我可以轻松地制作和订阅各种源。&lt;/p&gt;
&lt;p&gt;我甚至尝试过将整本书的所有章节、某个网站的全部内容制作成 RSS 源，每天推送一篇给自己。这种方式既新颖又有趣。&lt;/p&gt;
&lt;p&gt;然而，在 2023 年，我几乎停止了使用 RSS。主要原因是我意识到自己花在信息流上的时间过多，影响了工作和生活的其他重要方面。每天花费数小时浏览信息流，大多只是快速浏览标题，真正深入阅读的内容寥寥无几。停用 RSS 后，我突然发现自己有了大量空闲时间，可以专注于看电影、学习新知识，而不必担心信息的积压。&lt;/p&gt;
&lt;p&gt;尽管暂停使用 RSS 可能会错过一些新鲜事，但实际上，那些重要的信息往往会通过其他渠道传递给我，例如微信群和朋友的分享。&lt;/p&gt;
&lt;p&gt;到了 2024 年，经过一段时间的反思和自我调整，我重新拾起了阅读 RSS 的习惯，在这过程中对 RSS 有了一些新的看法。&lt;/p&gt;
&lt;h2&gt;RSS 已死？&lt;/h2&gt;
&lt;p&gt;“RSS 已死”的论调时有耳闻。持这种观点的人通常会举出以下例子：&lt;/p&gt;
&lt;p&gt;首先，像 Google 这样的科技巨头早在十多年前就放弃了对 RSS 的支持，似乎各大公司都在有意无意地边缘化这项技术。国内的微信构建了一个封闭的生态系统，其他以 RSS 为卖点的产品在国内市场也难以立足，甚至面临下架的风险。&lt;/p&gt;
&lt;p&gt;即使在国外，情况也不容乐观。Twitter 在被马斯克收购并改名为 X 后，进一步收紧了对 RSS 的限制，甚至对 RSSHub 的实例发动了 DDoS 攻击。&lt;/p&gt;
&lt;p&gt;在这样的打压下，RSS 似乎已被时代遗忘。就我身边的情况来看，多年来与我共事的同事中，使用 RSS 的人寥寥无几，甚至有人根本不知道 RSS 为何物。&lt;/p&gt;
&lt;p&gt;然而，2024 年初，RSSHub 的作者 DIYgod 联合推出了一款新的 RSS 阅读器——Follow。这款产品的出现，引起了广泛的关注。由于邀请码稀缺，各大论坛充斥着关于 Follow 的抽奖和邀请码交易的帖子。一时间，RSS 似乎又回到了大众的视野。&lt;/p&gt;
&lt;p&gt;在独立开发者的圈子里，也有越来越多的人开始讨论开发自己的 RSS 阅读器产品。Follow 的出现，的确让一些原本不了解 RSS 的人开始关注和使用它。这似乎与“RSS 已死”的论调相矛盾，RSS 真的要复兴了吗？&lt;/p&gt;
&lt;h2&gt;被忽视的力量&lt;/h2&gt;
&lt;p&gt;那么，Follow 的出现能否让 RSS“死而复生”呢？我对此持谨慎的态度。我的看法与一篇&lt;a href=&quot;https://example.com&quot;&gt;文章&lt;/a&gt;中所表达的观点非常相似。&lt;/p&gt;
&lt;p&gt;我认为，正是因为国内外的科技巨头对 RSS 缺乏兴趣，他们才没有过多干涉 RSS 的发展。这些公司借鉴了 RSS 的理念，打造了自己的封闭式信息流产品，如微信公众号等，但这并不是真正的 RSS。恰恰是这种“被忽视”，使得 RSS 得以保持其纯粹性和开放性。&lt;/p&gt;
&lt;p&gt;相反，对于邮件订阅等渠道，科技巨头们却非常热衷，结果导致用户经常收到大量的营销邮件，甚至需要花费精力去取消订阅。&lt;/p&gt;
&lt;p&gt;因此，被主流所忽视，反而成为了 RSS 的护身符。它不受商业利益的驱使，能够为用户提供一个自主选择信息的工具。在信息茧房泛滥的今天，RSS 为少数坚持自主获取信息的人提供了一片净土。&lt;/p&gt;
&lt;p&gt;Follow 的出现，虽然在一定程度上提高了 RSS 的关注度，但我认为更重要的是它为 RSS 注入了新的活力，让更多人认识到 RSS 的价值。然而，RSS 的核心魅力在于其开放性和用户自主性，这些特质正是由于缺乏商业巨头的干涉而得以保留。&lt;/p&gt;
&lt;h2&gt;结语&lt;/h2&gt;
&lt;p&gt;或许，RSS 并不需要复兴，因为它从未真正消亡。它一直存在于那些追求信息自由和自主选择的人们的工具箱中。被忽视，反而使 RSS 免受商业化的侵蚀，保留了其原本的纯粹。这种在夹缝中生存的状态，或许正是一种另类的胜利。&lt;/p&gt;
</content:encoded></item></channel></rss>