Reading view

There are new articles available, click to refresh the page.

为 Docusaurus 添加 Shiki 代码高亮支持

本文记录 https://docs.halo.run 集成 Shiki 代码高亮的过程。

引言

从 Halo 1.x 开始,我们就一直在使用 Docusaurus 来构建 Halo 的文档。直到 Halo 2.21,我们已经累积了大量的文档,期间发现代码块高亮的问题难以解决。Docusaurus 默认使用 Prism.js 来渲染代码块,且几乎没有其他选择。而我们的文档中使用的一些语言或框架(如 Vue),Prism 并没有提供高亮支持,因此长期以来这些代码块都没有显示语法高亮。即使是 Prism 所支持的语言,渲染出来的语法高亮效果也不尽如人意。

直到后来了解到了 https://shiki.style/,一个较新的代码高亮库,基于与 VSCode 同源的 TextMate 代码高亮引擎,渲染出来的代码效果与 VSCode 一致,并且支持现在主流语言和框架代码的高亮。我认为这几乎是当前各方面最优的代码高亮库。后面我们也为 Halo 开发了 Shiki 代码高亮插件,表现十分良好。因此最近也考虑将 Halo 文档中的代码高亮渲染改为使用 Shiki。好在搜索一番后,已经有了一个广泛讨论的 issue:https://github.com/facebook/docusaurus/issues/9122,并且里面也有人提供了可行的方案。于是我按照这个方案并添加了一些额外功能,本文将记录实现的完整过程。如果你也在用 Docusaurus,并且对 Shiki 有需求,可以参考本文。

背景

  • 目前使用的 Docusaurus 版本是 3.8.0

安装依赖

Docusaurus 的 Markdown 引擎核心为 https://mdxjs.com/,而 MDX 内部基于 remarkrehype,因此 Docusaurus 实际上预留了可以添加 rehype 插件的配置,所以可以直接使用 Shiki 官方的 rehype 插件

pnpm add @shikijs/rehype shiki -D

添加配置

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
// [!code ++:11]
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
                theme: "catppuccin-mocha",
                langs: Object.keys(bundledLanguages),
                // or
                // langs: ['js', 'ts']
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

其中,langs 可以只填写所需的语言列表,我这里为了省事直接添加 Shiki 所有语言,主要是因为文档太多,已经懒得去统计用到了哪些语言。

此外,theme 也可以指定多主题,如果需要让文档的暗色和亮色模式下代码块的主题不同,可以按照下面的方式更改:

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
// [!code --]
                theme: "catppuccin-mocha",
// [!code ++:4]
                themes: {
                  light: "github-light",
                  dark: "github-dark"
                },
                langs: Object.keys(bundledLanguages),
                // or
                // langs: ['js', 'ts']
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

module.exports = config;

然后在 custom.css 中添加:

[data-theme="dark"] pre {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
}
[data-theme="dark"] pre span {
  color: var(--shiki-dark) !important;
}

由于我期望亮色和暗色模式下都使用暗色的代码块主题,所以没有添加多主题配置。

组件覆盖

由于需要完全让 @shikijs/rehype 接管 Markdown 文档中的代码块渲染,我们需要覆盖 Docusaurus 内部 Pre/Code 的组件,避免被默认的 Prism 处理。Docusaurus 默认提供了 CLI 用于导出 Docusaurus 主题中的组件。

npx docusaurus swizzle @docusaurus/theme-classic MDXComponents/Code --typescript --eject

然后打开 src/theme/MDXComponents/Code.tsx 并修改为:

import type { ComponentProps, ReactNode } from "react";
import React from "react";
import CodeInline from "@theme/CodeInline";
import type { Props } from "@theme/MDXComponents/Code";

function shouldBeInline(props: Props) {
  return (
    // empty code blocks have no props.children,
    // see https://github.com/facebook/docusaurus/pull/9704
    typeof props.children !== "undefined" &&
    React.Children.toArray(props.children).every(
      (el) => typeof el === "string" && !el.includes("\n")
    )
  );
}

// [!code ++:3]
function CodeBlock(props: ComponentProps<"code">): JSX.Element {
  return <code {...props} />;
}

export default function MDXCode(props: Props): ReactNode {
  return shouldBeInline(props) ? (
    <CodeInline {...props} />
  ) : (
    <CodeBlock {...(props as ComponentProps<typeof CodeBlock>)} />
  );
}
npx docusaurus swizzle @docusaurus/theme-classic MDXComponents/Pre --typescript --eject

然后打开 src/theme/MDXComponents/Pre.tsx 并修改为:

import React, { type ReactNode } from "react";
import type { Props } from "@theme/MDXComponents/Pre";
export default function MDXPre(props: Props): ReactNode | undefined {
  return <pre {...props} />;
}

小插曲:当时到了这一步的时候,突然意识到似乎可以复用之前为 Halo 开发 Shiki 插件时发布的 NPM 包(@halo-dev/shiki-code-element)。因为这个包封装了一个 Web Component,所以肯定可以用在这里,只需要在 pre 标签外包裹一个 shiki-code 即可。尝试了一下确实可行,但这样就必须在客户端渲染了。虽然可行,但始终不如在构建阶段就渲染好。虽然可以尝试使用 Lit SSR,但考虑到文档中有一些代码块使用了 Title Meta,而目前 @halo-dev/shiki-code-element 还不支持,所以放弃了这个方案。

完成这一步之后,就可以尝试启动开发服务器了。不出意外的话,代码块就可以正常使用 Shiki 来渲染了。

添加标题支持

原来 Docusaurus 的默认方案是支持为代码块添加顶部标题的,切换到 Shiki 之后,这一部分需要自行实现,以下是具体步骤:

首先为 Shiki 添加一个自定义的 Transformer,用于解析标题的书写语法和添加代码块参数:

创建 src/shiki/meta-transformer.js

function parseTitleFromMeta(meta) {
  if (!meta) {
    return "";
  }
  const kvList = meta.split(" ").filter(Boolean);
  for (const item of kvList) {
    const [k, v = ""] = item.split("=").filter(Boolean);
    if (k === "title" && v.length > 0) {
      return v.replace(/["'`]/g, "");
    }
  }
  return "";
}

export function transformerAddMeta() {
  return {
    name: "shiki-transformer:add-meta",
    pre(pre) {
      const title = parseTitleFromMeta(this.options.meta?.__raw);
      if (title.length > 0) {
        pre.properties = {
          ...pre.properties,
          "data-title": title,
        };
      }
      return pre;
    },
  };
}

然后修改配置:

const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");
const { transformerAddMeta } = require("./src/shiki/meta-transformer");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
                theme: "catppuccin-mocha",
                langs: Object.keys(bundledLanguages),
// [!code ++:3]
                transformers: [
                  transformerAddMeta(),
                ]
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

修改 Pre.tsx 显示标题:

import React, { type ReactNode } from "react";
import type { Props } from "@theme/MDXComponents/Pre";

type PreWithDataTitle = Props & { "data-title"?: string };

export default function MDXPre(props: Props): ReactNode | undefined {
  const title = props["data-title"];
  return (
    <div
      style={{
        ...props.style,
        borderRadius: "var(--ifm-pre-border-radius)",
      }}
      className="shiki-code-wrapper"
    >
      {title && (
        <div className="shiki-code-header">
          <span>{title}</span>
        </div>
      )}
      <div className="shiki-code-content">
        <pre {...props} ref={preRef} />
      </div>
    </div>
  );
}

这里还修改了 MDXPre 标签的组件结构,为后续的功能做准备,其中最外层的 div 添加的 style 属性来自于 Shiki 渲染结果的 pre 标签的样式,包含背景色和字体默认颜色。

最后我们需要为自定义的 MDXPre 组件结构添加样式,这里为了让结构看起来更清晰,我引入了 SASS 插件:

# 安装所需依赖
pnpm add docusaurus-plugin-sass sass -D

修改配置文件:

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        // ...
        theme: {
// [!code --]
          customCss: require.resolve("./src/css/custom.css"),
// [!code ++:4]
          customCss: [
            require.resolve("./src/css/custom.css"),
            require.resolve("./src/css/shiki.scss"),
          ],
        },
      }),
    ],
  ],
// [!code ++]
  plugins: [require.resolve("docusaurus-plugin-sass")],
};

然后创建 src/css/shiki.scss

.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }
}

这样就支持使用原有的语法为代码块添加标题了。

显示行号

Shiki 原生并不支持在渲染的 HTML 结果中包含行号信息,但社区中有人提供了一种使用纯 CSS 实现的方案,详见:https://github.com/shikijs/shiki/issues/3#issuecomment-830564854

修改 shiki.scss

.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }
// [!code ++:36]
  .shiki-code-content {
    position: relative;

    pre {
      position: relative;
      padding: 0.75rem;
      margin: 0;
      border-radius: initial;
      code {

        counter-reset: step;
        counter-increment: step 0;

        .line {
          position: relative;
        }

        // line numbers start
        .line::before {
          content: counter(step);
          counter-increment: step;
          width: 0.6rem;
          margin-right: 1.1rem;
          display: inline-block;
          text-align: right;
          color: rgba(115, 138, 148, 0.5);
          user-select: none;
        }

        .line:last-child:empty::before {
          content: none;
          counter-increment: none;
        }
        // line numbers end
      }
    }
  }
}

这样就可以默认为所有代码块添加行号显示了。

复制按钮

Docusaurus 默认的代码块有复制按钮,改为 Shiki 之后这部分也需要自行实现,以下是具体步骤:

修改 src/theme/MDXComponents/Pre.tsx

import React, { type ReactNode, useRef, useState } from "react";
import type { Props } from "@theme/MDXComponents/Pre";

type PreWithDataTitle = Props & { "data-title"?: string };

export default function MDXPre(props: PreWithDataTitle): ReactNode | undefined {
  const title = props["data-title"];
// [!code ++:11]
  const preRef = useRef<HTMLPreElement>(null);
  const [copied, setCopied] = useState(false);

  const handleCopy = () => {
    const code = preRef.current?.innerText || preRef.current?.textContent || "";

    copyText(code, () => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    });
  };

  return (
    <div
      style={{
        ...props.style,
        borderRadius: "var(--ifm-pre-border-radius)",
      }}
      className="shiki-code-wrapper"
    >
      {title && (
        <div className="shiki-code-header">
          <span>{title}</span>
        </div>
      )}
      <div className="shiki-code-content">
// [!code ++:8]
        <button
          className="shiki-code-copy-button"
          onClick={handleCopy}
          title={copied ? "已复制!" : "复制代码"}
          style={{ ...props.style }}
        >
          <i className={copied ? "tabler--check" : "tabler--copy"}></i>
        </button>
        <pre {...props} ref={preRef} />
      </div>
    </div>
  );
}

// [!code ++:24]
export function copyText(text: string, cb: () => void) {
  if (navigator.clipboard) {
    navigator.clipboard.writeText(text).then(() => {
      cb();
    });
  } else {
    const textArea = document.createElement("textarea");
    textArea.value = text;
    textArea.style.position = "fixed";
    textArea.style.opacity = "0";
    document.body.appendChild(textArea);
    textArea.focus();
    textArea.select();
    try {
      const successful = document.execCommand("copy");
      if (successful) {
        cb();
      }
    } catch (err) {
      console.error("Fallback: Oops, unable to copy", err);
    }
    document.body.removeChild(textArea);
  }
}

添加样式:

.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }

  .shiki-code-content {
    position: relative;

    pre {
      position: relative;
      padding: 0.75rem;
      margin: 0;
      border-radius: initial;
      code {

        counter-reset: step;
        counter-increment: step 0;

        .line {
          position: relative;
        }

        // line numbers start
        .line::before {
          content: counter(step);
          counter-increment: step;
          width: 0.6rem;
          margin-right: 1.1rem;
          display: inline-block;
          text-align: right;
          color: rgba(115, 138, 148, 0.5);
          user-select: none;
        }

        .line:last-child:empty::before {
          content: none;
          counter-increment: none;
        }
        // line numbers end
      }
    }
// [!code ++:15]
    .shiki-code-copy-button {
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      opacity: 0;
      z-index: 2;
      width: 2rem;
      height: 2rem;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0.4rem;
      border: none;
      cursor: pointer;
    }
  }
// [!code ++:33]
  &:hover {
    .shiki-code-copy-button {
      opacity: 1;
    }
  }

  .tabler--copy {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M7 9.667A2.667 2.667 0 0 1 9.667 7h8.666A2.667 2.667 0 0 1 21 9.667v8.666A2.667 2.667 0 0 1 18.333 21H9.667A2.667 2.667 0 0 1 7 18.333z'/%3E%3Cpath d='M4.012 16.737A2 2 0 0 1 3 15V5c0-1.1.9-2 2-2h10c.75 0 1.158.385 1.5 1'/%3E%3C/g%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }

  .tabler--check {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12l5 5L20 7'/%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }
}

这样就可以在鼠标悬停到代码块时显示复制按钮了。

集成必要的 Transformer

Shiki 官方提供了一些非常有用的 Transformers,比如行高亮、代码对比等,这里根据自身需要添加即可。

pnpm add -D @shikijs/transformers
const { bundledLanguages } = require("shiki");
const { default: rehypeShiki } = require("@shikijs/rehype");
const { transformerAddMeta } = require("./src/shiki/meta-transformer");
// [!code ++:4]
const { transformerMetaHighlight } = require("@shikijs/transformers");
const { transformerNotationDiff } = require("@shikijs/transformers");
const { transformerNotationFocus } = require("@shikijs/transformers");
const { transformerNotationErrorLevel } = require("@shikijs/transformers");

/** @type {import('@docusaurus/types').Config} */
const config = {
  // ...
  presets: [
    [
      "classic",
      /** @type {import('@docusaurus/preset-classic').Options} */
      ({
        docs: {
          // ...
          beforeDefaultRehypePlugins: [
            [
              rehypeShiki,
              {
                theme: "catppuccin-mocha",
                langs: Object.keys(bundledLanguages),
                transformers: [
// [!code ++:8]
                  // 行高亮,使用 Meta 信息的方式,比如 ```java {1}
                  transformerMetaHighlight(),
                  // 代码对比,使用注释的方式
                  transformerNotationDiff(),
                  // 行聚焦,使用注释的方式
                  transformerNotationFocus(),
                  // 行高亮的错误和警告变体,使用注释的方式
                  transformerNotationErrorLevel(),
                  transformerAddMeta(),
                ]
              },
            ],
          ],
          // ...
        },
      }),
    ],
  ],
  // ...
};

这些 Transformers 只是为对应的行添加了 class,我们需要自行实现样式。以下是完整的 shiki.scss

点击查看 shiki.scss
.shiki-code-wrapper {
  overflow: hidden;
  margin-bottom: var(--ifm-leading);
  color-scheme: dark;

  .shiki-code-header {
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--ifm-color-gray-700);
    font-size: var(--ifm-code-font-size);
  }

  .shiki-code-content {
    position: relative;

    pre {
      position: relative;
      padding: 0.75rem;
      margin: 0;
      border-radius: initial;
      code {
        z-index: 1;
        display: block;
        width: max-content;
        position: relative;
        min-width: 100%;

        counter-reset: step;
        counter-increment: step 0;

        .line {
          position: relative;
        }

        // line numbers start
        .line::before {
          content: counter(step);
          counter-increment: step;
          width: 0.6rem;
          margin-right: 1.1rem;
          display: inline-block;
          text-align: right;
          color: rgba(115, 138, 148, 0.5);
          user-select: none;
        }

        .line:last-child:empty::before {
          content: none;
          counter-increment: none;
        }
        // line numbers end

        // highlighted lines start
        .highlighted {
          width: 100%;
          display: inline-block;
          position: relative;
        }

        .highlighted::after {
          content: "";
          position: absolute;
          top: 0;
          bottom: 0;
          left: -0.75rem;
          right: -0.75rem;
          background: rgba(101, 117, 133, 0.16);
          border-left: 1px solid rgba(34, 197, 94, 0.8);
          z-index: 0;
        }

        .highlighted.error::after {
          background: rgba(244, 63, 94, 0.16) !important;
        }

        .highlighted.warning::after {
          background: rgba(234, 179, 8, 0.16) !important;
        }
        // highlighted lines end
      }

      // focus line start
      &.has-focused .line:not(.focused) {
        opacity: 0.7;
        filter: blur(0.095rem);
        transition: filter 0.35s, opacity 0.35s;
      }

      &.has-focused:hover .line:not(.focused) {
        opacity: 1;
        filter: blur(0);
      }
      // focus line end

      // diff start
      &.has-diff .diff {
        width: 100%;
        display: inline-block;
        position: relative;
      }

      &.has-diff .diff.remove::before {
        content: "-";
      }

      &.has-diff .diff.add::before {
        content: "+";
      }

      &.has-diff .diff.remove::after {
        content: "";
        position: absolute;
        top: 0;
        bottom: 0;
        left: -0.75rem;
        right: -0.75rem;
        background: rgb(239 68 68 / 0.15);
        border-left: 1px solid rgb(239 68 68 / 0.8);
        z-index: -1;
      }

      &.has-diff .diff.add::after {
        content: "";
        position: absolute;
        top: 0;
        bottom: 0;
        left: -0.75rem;
        right: -0.75rem;
        background: rgb(34 197 94 / 0.15);
        border-left: 1px solid rgb(34 197 94 / 0.8);
        z-index: -1;
      }
      // diff end
    }

    .shiki-code-copy-button {
      position: absolute;
      top: 0.5rem;
      right: 0.5rem;
      opacity: 0;
      z-index: 2;
      width: 2rem;
      height: 2rem;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0.4rem;
      border: none;
      cursor: pointer;
    }
  }

  &:hover {
    .shiki-code-copy-button {
      opacity: 1;
    }
  }

  .tabler--copy {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M7 9.667A2.667 2.667 0 0 1 9.667 7h8.666A2.667 2.667 0 0 1 21 9.667v8.666A2.667 2.667 0 0 1 18.333 21H9.667A2.667 2.667 0 0 1 7 18.333z'/%3E%3Cpath d='M4.012 16.737A2 2 0 0 1 3 15V5c0-1.1.9-2 2-2h10c.75 0 1.158.385 1.5 1'/%3E%3C/g%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }

  .tabler--check {
    display: inline-block;
    width: 28px;
    height: 28px;
    --svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m5 12l5 5L20 7'/%3E%3C/svg%3E");
    background-color: currentColor;
    -webkit-mask-image: var(--svg);
    mask-image: var(--svg);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-size: 100% 100%;
    mask-size: 100% 100%;
  }
}

至此,一个比较好看、功能丰富的代码高亮改造方案就完成了,具体修改代码也可以查阅 Halo 文档的 PR:https://github.com/halo-dev/docs/pull/521,也可以访问 Halo 文档查看修改之后的效果。

参考资料

使用 Meilisearch 提升 Halo 网站的搜索体验

原文地址:https://www.lxware.cn/archives/halo-meilisearch

Halo 从 2.0 版本开始支持了全文搜索功能,自带的 Lucene 搜索引擎在轻度使用场景下可以满足需求,但在重度依赖搜索功能的场景下,可能在搜索速度和用户体验上存在不足,这时我们会更推荐使用独立的搜索引擎。

本文将介绍如何使用 https://www.meilisearch.com/ 搜索引擎来提升 Halo 网站的搜索体验。

搭建 Meilisearch 服务

部署 Meilisearch 服务通常有两种方式:你可以选择自行在服务器上托管,或者使用 Meilisearch 官方提供的云服务。

云服务

访问 https://www.meilisearch.com/cloud 注册账号,根据引导创建项目。创建完成后,可以在控制台获取实例地址和 Master Key。

需要特别注意 Meilisearch 云服务的计费方式。

自托管

下面介绍两种常见的部署方式:

使用 Docker Compose 独立编排部署

这种方式适合多个项目需要同时使用一个 Meilisearch 服务的场景。 部署完成后,你可以配置域名和反向代理来暴露服务到公网。

services:
  meilisearch:
  image: getmeili/meilisearch:v1.15
  restart: unless-stopped
  ports:
    - "7700:7700"
  environment:
    - MEILI_ENV=production
// [!code highlight]
    - MEILI_MASTER_KEY=<your-super-secret-master-key-here>
  volumes:
    - meilisearch_data:/meili_data

volumes:
  meilisearch_data:
    driver: local

与 Halo 的 Compose 编排一起部署

结合 使用 Docker Compose 部署 Halo 的示例,将 Meilisearch 服务添加到 docker-compose.yml 文件中。

通过这种方式部署之后,插件设置中的 Meilisearch 服务地址 应该是 http://meilisearch:7700(即服务在同一 Compose 网络下可通过服务名访问)

meilisearch:
  image: getmeili/meilisearch:v1.15
  restart: on-failure:3
  networks:
    - halo_network
  volumes:
    - ./meilisearch-data:/meili_data
  environment:
    - MEILI_ENV=production
// [!code highlight]
    - MEILI_MASTER_KEY=<your-super-secret-master-key-here>
点击查看完整示例
version: "3"

services:
  halo:
    image: registry.fit2cloud.com/halo/halo:2.21
    restart: on-failure:3
    depends_on:
    halodb:
      condition: service_healthy
    networks:
    halo_network:
    volumes:
      - ./halo2:/root/.halo2
    ports:
      - "8090:8090"
    healthcheck:
    test:
      ["CMD", "curl", "-f", "http://localhost:8090/actuator/health/readiness"]
    interval: 30s
    timeout: 5s
    retries: 5
    start_period: 30s
    environment:
      - JVM_OPTS=-Xmx256m -Xms256m
    command:
      - --spring.r2dbc.url=r2dbc:pool:postgresql://halodb/halo
      - --spring.r2dbc.username=halo
      - --spring.r2dbc.password=openpostgresql
      - --spring.sql.init.platform=postgresql
      - --halo.external-url=http://localhost:8090/
  halodb:
    image: postgres:15.4
    restart: on-failure:3
    networks:
    halo_network:
    volumes:
      - ./db:/var/lib/postgresql/data
    healthcheck:
    test: ["CMD", "pg_isready"]
    interval: 10s
    timeout: 5s
    retries: 5
    environment:
      - POSTGRES_PASSWORD=openpostgresql
      - POSTGRES_USER=halo
      - POSTGRES_DB=halo
      - PGUSER=halo
// [!code focus:10]
  meilisearch:
    image: getmeili/meilisearch:v1.15
    restart: on-failure:3
    networks:
      - halo_network
    volumes:
      - ./meilisearch-data:/meili_data
    environment:
      - MEILI_ENV=production
      - MEILI_MASTER_KEY=<your-super-secret-master-key-here>

networks:
  halo_network:

详细的部署方式可以参考 Meilisearch 官方文档:https://www.meilisearch.com/docs/learn/self_hosted/install_meilisearch_locally

安装插件

之前 Halo 社区中已经有人开发了 Meilisearch 插件,但已经不再维护,因此这里我们选择使用 Halo 官方提供的插件。

  1. 下载插件,目前提供以下两种下载方式:

  2. 安装插件,插件安装和更新方式可参考:https://docs.halo.run/user-guide/plugins

配置插件

  1. 进入插件设置,配置 Meilisearch 服务地址Master Key索引名称 可以选择使用默认的 halo 或者自定义(如果你的 Meilisearch 服务会被多个项目使用,建议自定义索引名称)。

  2. 进入插件扩展配置,在 扩展点定义 中选择 搜索引擎,然后选择使用 Meilisearch。

数据概览

配置完插件后,我们可以进入插件的 数据概览 页面,查看 Meilisearch 的索引数据。

在这个页面中,你还可以重建索引或测试搜索功能。

对比

Lucene(默认搜索引擎)与 Meilisearch 的实际对比:

Meilisearch

Lucene(默认)

通过实际使用可以发现,Meilisearch 的搜索结果更加准确,搜索速度更快,并且支持更灵活的搜索语法,无需用户掌握复杂的搜索表达式即可获得理想的搜索结果。

注意

  1. 如果配置完 Meilisearch 插件之后无法搜索,可以尝试重建一次索引。

  2. 安装 Meilisearch 插件之后仍然需要https://www.halo.run/store/apps/app-DlacW插件,Meilisearch 插件仅仅是提供服务,不会提供 UI。

在 Halo 中导入 Markdown 和 Word 文档

原文地址:https://www.lxware.cn/archives/halo-content-tools

在 Halo 社区中,导入 Markdown 和 Word 文档的需求一直很高,但社区一直缺乏完善的解决方案。其主要原因在于 Markdown 和 Word 的文档格式较为复杂,难以完美支持所有格式特性,且图片资源的处理存在技术难点。

现在,社区中已经有了一个插件可以很好地支持导入 Markdown 和 Word 文档,它就是 https://www.halo.run/store/apps/app-SUvBR。该插件不仅支持导入 Markdown 和 Word 文档,还能够智能处理和导入图片资源,为用户提供了完整的文档迁移解决方案。

安装

可以通过以下两种方式安装插件:

  1. 访问 https://www.halo.run/store/apps/app-SUvBR 页面直接下载

  2. 在 Console 内置的应用市场中搜索 内容助手 进行安装

导入 Markdown 文档

安装并启用插件后,就可以在 Console 侧边菜单的工具中找到 文章导入 的入口。点击进入后,选择 Markdown 导入 选项卡即可开始导入,如下图:

功能详解

  1. 选择 Markdown 文件:用于选择单个 Markdown 文档,支持 .md 格式文件。

  2. 选择 Markdown 文件夹:用于选择包含 Markdown 文档的文件夹。选择文件夹后,系统会自动扫描其中的所有 Markdown 文档以及图片资源(如有)。

  3. 选择图片文件夹:用于选择 Markdown 文档中引用的图片资源。选择文件夹后,系统会自动扫描其中的所有图片资源并在导入时自动关联。

  4. 转为富文本格式:默认情况下,导入的 Markdown 文档会保持原有的 Markdown 格式。如果勾选此选项,系统会将文档转换为富文本格式,便于后续使用 Halo 的默认编辑器进行编辑。

使用场景

  • 从其他博客平台或写作工具迁移文章内容

  • 导入使用本地 Markdown 编辑器创作的文章

  • 批量导入历史文档和资料

注意事项

  1. 导入 Markdown 文档后,如果需要在 Console 中编辑文章,请确保已经安装了任意一个 Markdown 编辑器插件,否则无法正常打开编辑页面。

  2. 如果 Markdown 文档中引用了本地图片资源,请在导入前选择存放图片的文件夹,否则图片将无法正确上传和关联。

  3. 系统支持自动解析 Front Matter(文档头部的元数据),包括标题、别名(slug)、描述、摘要、分类、标签等信息。

操作示例

选择 Markdown 文件:

选择图片文件夹(如果文档包含本地图片):

点击导入,等待导入完成:

检查文章与图片资源是否导入成功:

导入 Word 文档

进入 文章导入 页面后,选择 Word(.docx)导入 选项卡,如下图:

功能详解

  1. 选择 Word 文档:用于选择单个 Word 文档,支持 .doc.docx 格式。

  2. 选择 Word 文档文件夹:用于批量选择包含 Word 文档的文件夹。

  3. 转为 Markdown 格式:默认情况下,导入的 Word 文档会转换为富文本格式。如果希望后续使用 Markdown 编辑器编辑文章,请勾选此选项将内容转换为 Markdown 格式。

使用场景

  • 将公司内部的 Word 文档转换为博客文章

  • 配合 https://maxkb.cn/ 智能知识库,建立企业知识管理体系

  • 从传统文档工具迁移内容到现代化的管理平台

注意事项

  1. 由于 Word 文档格式的复杂性,系统可能无法完美解析所有内容格式,建议导入后进行适当调整。

  2. 系统支持自动导入 Word 文档中的图片资源,但其他类型的嵌入对象暂不支持。

  3. 图片会上传到与个人中心关联的存储策略,请提前在用户设置中配置相关参数。

操作示例

选择 Word 文档:

点击导入,等待导入完成:

检查文章是否导入成功:

丰富的内容管理功能

除了核心的导入功能,内容助手还提供了丰富的内容管理功能:

格式转换

支持 Markdown 与富文本格式的双向转换,让用户可以根据编辑需求灵活切换文档格式。你可以在文章管理页面点击文章的 ··· 按钮,在转换菜单中选择相应的格式转换选项,也可以在文章编辑页面顶部的编辑器选择框中选择 内容格式转换器 进行转换。

文章导出

支持将文章导出为多种格式,方便内容备份和分享:

  • 以原格式导出:保持文章的原始格式进行导出

  • 转换为 Markdown 并导出:将文章转换为 Markdown 格式后导出

  • 转换为 PDF 并导出:将文章转换为 PDF 格式进行导出

文章克隆

提供文章克隆功能,便于基于现有文章创建相似内容。克隆后的文章会自动在标题后添加"(副本)"标识,并生成新的别名以避免冲突。

以上功能都可以在文章管理页面点击文章的 ··· 按钮找到相应选项。

总结

内容助手插件为 Halo 用户提供了完整的文档导入和内容管理解决方案,有效解决了 Markdown 和 Word 文档的导入难题。插件不仅支持智能处理图片资源,还提供了格式转换、文章导出、文章克隆、内容复制等丰富功能,能够满足大部分用户的内容管理需求。

无论你是从其他平台迁移内容,还是需要批量导入历史文档,内容助手都能为你提供便捷、高效的解决方案。如果你有文档导入或内容格式转换的需求,欢迎尝试使用内容助手插件。

通过 1Panel MCP 自动部署静态网站

原文地址:https://www.lxware.cn/archives/1panel-mcp

引言

随着大语言模型(LLM)技术的快速发展,我们正在见证软件开发领域的一场革命。从 ChatGPT 到 Claude,从 GitHub Copilot 到各种 AI 编程助手,人工智能正在深刻改变着开发者的工作方式。

在这个 AI 驱动的时代,Agent(智能代理)概念应运而生。Agent 不仅能理解自然语言指令,还能执行复杂的任务流程,真正实现了"对话式编程"的愿景。而 MCP(Model Context Protocol)作为连接 AI 模型与外部工具的标准协议,为构建强大的 AI Agent 提供了技术基础。

MCP 的出现解决了一个关键问题:如何让 AI 模型安全、高效地与各种外部系统交互。通过标准化的协议,开发者可以创建各种 MCP 工具,让 AI 助手能够执行文件操作、API 调用、数据库查询等复杂任务。

本文将介绍如何使用 https://github.com/ruibaby/1Panel-mcp 工具,在 AI 编辑器中实现自动将网站项目部署到 1Panel 中。

配置

https://github.com/ruibaby/1Panel-mcp 中只提供了一个工具,即 deploy_website,用于将静态网站项目部署到 1Panel 中,并支持自动创建网站配置。下面将主要介绍在 VSCode 和 Cursor 中如何配置并使用此工具。

VSCode:

打开 VSCode 的配置文件,添加以下配置:

{
  "mcp": {
    "inputs": [],
    "servers": {
      "1panel-mcp": {
        "command": "npx",
        "args": [
          "-y",
          "1panel-mcp"
        ],
        "env": {
// [!code highlight:3]
          "ONEPANEL_API_KEY": "TOSXWBVfcG7dLlD1Gj0DK5D4L9tKz6FF",
          "ONEPANEL_BASE_URL": "http://127.0.0.1:34300/",
          "ONEPANEL_API_VERSION": "v2"
        }
      }
    }
  }
}

配置完成后保存,然后在 Copilot Chat 的界面可以看到 1panel-mcpdeploy_website 工具,即代表配置成功。

Cursor:

打开 Cursor 的设置界面:

然后在 MCP 配置文件中添加以下配置:

{
  "mcpServers": {
    "1panel-mcp": {
      "command": "npx",
      "args": [
        "-y",
        "1panel-mcp"
      ],
      "env": {
// [!code highlight:3]
        "ONEPANEL_API_KEY": "TOSXWBVfcG7dLlD1Gj0DK5D4L9tKz6FF",
        "ONEPANEL_BASE_URL": "http://127.0.0.1:34300/",
        "ONEPANEL_API_VERSION": "v2"
      }
    }
  }
}

然后回到设置界面,可以看到 1panel-mcpdeploy_website 工具,即代表配置成功。

参数说明

  • ONEPANEL_BASE_URL: 1Panel 的 API 地址

  • ONEPANEL_API_KEY: 1Panel 的 API 密钥,可以在 1Panel 控制台设置中获取

  • ONEPANEL_API_VERSION: 1Panel 的 API 版本,可选值为 v1v2,默认值为 v2

使用

配置完成后,我们就可以打开任意的静态网站项目并测试这个 MCP 工具,可以使用以下提示词:

# 将当前项目部署到 1Panel 中,域名为 halocms.net。

需要注意,如果你指定的域名不存在,工具会自动创建一个新网站,并设置指定的域名。

演示

为了方便演示,我创建了一个新的 Vue 项目,并让 AI 帮我部署到 1Panel,以下是完整过程:

部署完成后,我们回到 1Panel 后台就可以看到新创建的网站和上传的文件:

后续我们完善了项目后,也可以让 AI 再次部署:

总结

通过以上演示,我们可以看到,使用 1Panel-mcp 工具,我们可以让 AI 自动将静态网站项目部署到 1Panel 中,并支持自动创建网站配置,大大提高了开发和部署效率。

参考

使用 Rspack 构建 Halo 插件的前端部分

更新(25-06-19)

现在已经为插件的 UI 部分提供了新的配置方式,https://www.npmjs.com/package/@halo-dev/ui-plugin-bundler-kit 包提供了 rsbuildConfig 方法,可以更加方便的使用 https://rsbuild.dev/ 来构建 UI 部分。

Rsbuild 基于 Rspack 构建,提供了更完善的 loader 配置,所以在封装的时候就直接选择了 Rsbuild。

安装依赖:

pnpm install @halo-dev/ui-plugin-bundler-kit@2.21.1 @rsbuild/core -D

rsbuild.config.mjs:

import { rsbuildConfig } from "@halo-dev/ui-plugin-bundler-kit";

export default rsbuildConfig()

package.json 添加 scripts:

{
  "type": "module",
  "scripts": {
    "dev": "rsbuild build --env-mode development --watch",
    "build": "rsbuild build"
  }
}

需要注意的是,为了适应新版的 https://github.com/halo-dev/plugin-starter,默认生产构建输出目录改为了 ui/build/dist ,如果你要从已有的插件项目迁移到 Rsbuild,建议参考 https://github.com/halo-dev/plugin-starter/pull/52 对 Gradle 脚本进行改动,或者自定义 Rsbuild 的配置以保持原有的输出目录配置:

import { rsbuildConfig } from "@halo-dev/ui-plugin-bundler-kit";

const OUT_DIR_PROD = "../src/main/resources/console";
const OUT_DIR_DEV = "../build/resources/main/console";

export default rsbuildConfig({
  rsbuild: ({ envMode }) => {
    const isProduction = envMode === "production";
    const outDir = isProduction ? OUT_DIR_PROD : OUT_DIR_DEV;

    return {
      resolve: {
        alias: {
          "@": "./src",
        },
      },
      output: {
        distPath: {
          root: outDir,
        },
      },
    };
  },
});

示例:https://github.com/halo-sigs/plugin-migrate

了解更多:https://docs.halo.run/developer-guide/plugin/basics/ui/build


前情提要

Halo 插件的 UI 部分(Console / UC)的实现方式其实很简单,本质上就是构建一个结构固定的大对象,交给 Halo 去解析,其中包括全局注册的组件、路由定义、扩展点等。 基于这个前提,在实现插件机制时,主要面临的问题就是如何将这个大对象传递给 Halo。当初做了非常多的尝试,最终选择构建为 IIFE(Immediately Invoked Function Expression,立即执行函数),然后 Halo 通过读取 window[PLUGIN_NAME](PLUGIN_NAME 即插件名)来获取这个对象。 构建方案采用 Vite,并提供了统一的构建配置。回过头来看,这个方案存在不少问题:

  1. 会污染 window 对象,虽然目前并没有出现因为这个导致的问题,但是从长远来看,这个方案并不是最优的。(当然,使用 Rspack 来构建并不是为了解决这个问题)

  2. Vite 不支持 IIFE / UMD 格式的代码分割(主要是 Rollup 还不支持),无法像 ESM(ECMAScript Module)那样实现异步加载模块的机制。

  3. 基于第 2 点,如果插件中实现了较多的功能,可能会导致最终产物体积巨大,尤其是当用户安装了过多的插件时,会导致页面加载缓慢。

    1. http://www.halo.run/ 为例,gzip 之前接近 10M 的 bundle.js,gzip 之后也有 2M - 3M。

    2. 以此博客为例,gzip 之后也有 1.8M 的 bundle.js。

  4. 基于第 2 点,如果不支持代码分块(Chunk),也无法充分利用资源缓存,访问页面时,也会一次性加载所有插件的代码(即便当前页面不需要)。

基于以上问题,我开始寻找其他替代方案,最终通过翻阅 Rspack(Webpack 的 Rust 实现)的文档发现,Webpack 能够通过配置实现 IIFE 格式的代码分割,最终选择 Rspack 作为尝试。

基本的 Rspack 配置

安装依赖:

pnpm install @rspack/cli @rspack/core vue-loader -D

package.json 添加 scripts:

{
  "type": "module",
  "scripts": {
    "dev": "NODE_ENV=development rspack build --watch",
    "build": "NODE_ENV=production rspack build"
  }
}

rspack.config.mjs:

import { defineConfig } from '@rspack/cli';
import path from 'path';
import process from 'process';
import { VueLoaderPlugin } from 'vue-loader';
import { fileURLToPath } from 'url';

// plugin.yaml 中的 metadata.name
const PLUGIN_NAME = '<YOUR_PLUGIN_NAME>';

const isProduction = process.env.NODE_ENV === 'production';
const dirname = path.dirname(fileURLToPath(import.meta.url));

// 开发环境启动直接输出到插件项目的 build 目录,无需重启整个插件
// 生产环境输出到插件项目的 src/main/resources/console 目录下
const outDir = isProduction ? '../src/main/resources/console' : '../build/resources/main/console';

export default defineConfig({
  mode: process.env.NODE_ENV,
  entry: {
    // 入口文件,可以参考:https://docs.halo.run/developer-guide/plugin/basics/ui/entry
    main: './src/index.ts',
  },
  plugins: [new VueLoaderPlugin()],
  resolve: {
    alias: {
      '@': path.resolve(dirname, 'src'),
    },
    extensions: ['.ts', '.js'],
  },
  output: {
    // 资源根路径,加载代码分块(Chunk)的时候,会根据这个路径去加载资源
    publicPath: `/plugins/${PLUGIN_NAME}/assets/console/`,
    chunkFilename: '[id]-[hash:8].js',
    cssFilename: 'style.css',
    path: path.resolve(outDir),
    library: {
      // 将对象挂载到 window 上
      type: 'window',
      export: 'default',
      name: PLUGIN_NAME,
    },
    clean: true,
    iife: true,
  },
  optimization: {
    providedExports: false,
    realContentHash: true,
  },
  experiments: {
    css: true,
  },
  devtool: false,
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: [/node_modules/],
        loader: 'builtin:swc-loader',
        options: {
          jsc: {
            parser: {
              syntax: 'typescript',
            },
          },
        },
        type: 'javascript/auto',
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          experimentalInlineMatchResource: true,
        },
      },
    ],
  },
  // 这部分依赖已经由 Halo 提供,所以需要标记为外部依赖
  externals: {
    vue: 'Vue',
    'vue-router': 'VueRouter',
    '@vueuse/core': 'VueUse',
    '@vueuse/components': 'VueUse',
    '@vueuse/router': 'VueUse',
    '@halo-dev/console-shared': 'HaloConsoleShared',
    '@halo-dev/components': 'HaloComponents',
    '@halo-dev/api-client': 'HaloApiClient',
    '@halo-dev/richtext-editor': 'RichTextEditor',
    axios: 'axios',
  },
});

配置需要懒加载的路由或者组件:

在 index.ts 中配置路由:

import { definePlugin } from '@halo-dev/console-shared';
import { defineAsyncComponent } from 'vue';
import { VLoading } from '@halo-dev/components';
import 'uno.css';
// [!code --]
import DemoPage from './views/DemoPage.vue';

export default definePlugin({
  routes: [
    {
      parentName: 'Root',
      route: {
        path: 'demo',
        name: 'DemoPage',
// [!code --]
        component: DemoPage,
// [!code ++:4]
        component: defineAsyncComponent({
          loader: () => import('./views/DemoPage.vue'),
          loadingComponent: VLoading,
        }),
      ...
      },
    },
  ],
  extensionPoints: {},
});

注:推荐使用 defineAsyncComponent 包裹,而不是直接使用 () => import() 的方式,后者会在进入路由之前就开始加载页面的代码分块(Chunk),导致页面在加载期间没有任何响应。

构建产物示例:

❯ ll src/main/resources/console
.rw-r--r-- 191k ryanwang staff 16 Jun 10:47  359-3bebb968.js
.rw-r--r--  83k ryanwang staff 16 Jun 10:47  962-3bebb968.js
.rw-r--r-- 4.1k ryanwang staff 16 Jun 10:47  main.js

其他配置

集成 Scss / Sass

安装依赖:

pnpm install sass-embedded sass-loader -D

rspack.config.mjs 添加配置:

import { defineConfig } from '@rspack/cli';
import path from 'path';
import process from 'process';
import { VueLoaderPlugin } from 'vue-loader';
import { fileURLToPath } from 'url';
// [!code ++]
import * as sassEmbedded from "sass-embedded";

...

export default defineConfig({
  ...
  module: {
    rules: [
      ...
// [!code ++:13]
      {
        test: /\.(sass|scss)$/,
        use: [
          {
            loader: "sass-loader",
            options: {
              api: "modern-compiler",
              implementation: sassEmbedded,
            },
         },
        ],
        type: "css/auto",
      },
    ],
  },
...
});

集成 UnoCSS

如果你习惯使用 TailwindCSS 或者 UnoCSS 来编写样式,可以参考以下配置:

本文推荐使用 https://unocss.dev/,因为可以利用 UnoCSS 的 https://unocss.dev/transformers/compile-class 来编译样式,预防与 Halo 或者其他插件产生样式冲突。

安装依赖:

pnpm install unocss @unocss/webpack @unocss/eslint-config style-loader css-loader -D

入口文件(src/index.ts)添加导入:

import 'uno.css';

rspack.config.mjs 添加配置:

import { defineConfig } from '@rspack/cli';
import path from 'path';
import process from 'process';
import { VueLoaderPlugin } from 'vue-loader';
import { fileURLToPath } from 'url';
// [!code ++]
import { UnoCSSRspackPlugin } from '@unocss/webpack/rspack';

...

export default defineConfig({
  ...
  plugins: [
    new VueLoaderPlugin(),
// [!code ++]
    UnoCSSRspackPlugin()
  ],
  ...
  module: {
    rules: [
      ...
// [!code ++:5]
      {
        test: /\.css$/i,
        use: ['style-loader', 'css-loader'],
        type: 'javascript/auto',
      },
    ],
  },
...
});

uno.config.ts:

import { defineConfig, presetWind3, transformerCompileClass } from 'unocss';

export default defineConfig({
  presets: [presetWind3()],
  transformers: [transformerCompileClass()],
});

.eslintrc.cjs:

/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution');

module.exports = {
  root: true,
  extends: [
    'plugin:vue/vue3-recommended',
    'eslint:recommended',
    '@vue/eslint-config-typescript/recommended',
    '@vue/eslint-config-prettier',
// [!code ++]
    '@unocss',
  ],
  env: {
    'vue/setup-compiler-macros': true,
  },
// [!code ++:3]
  rules: {
    "@unocss/enforce-class-compile": 1,
  },
};

总结

以上就是针对 Halo 插件前端部分的 Rspack 配置。我已经对 Halo 官方维护的部分插件进行了迁移,几乎没有遇到什么问题,并且带来的收益非常明显:www.halo.run 和本博客的 bundle.js 在 gzip 之后仅有不到 200k,各个页面也只会在访问时加载所需的资源。

需要注意的是,我对这些构建工具并不算非常熟悉,所以配置仍然有优化空间。我们会持续优化,后续也会考虑提供一个通用的 CLI 或 Rspack 配置,期望实现如下效果:

rspack.config.mjs:

import { rspackConfig } from '@halo-dev/ui-bundler-kit';

export default rspackConfig({
  ...
});

或者基于 Rspack 包装一个 CLI:

plugin.config.mjs:

import { defineConfig } from '@halo-dev/ui-bundler-kit';

export default defineConfig({
  ...
});

package.json:

{
  "scripts": {
    "dev": "halo-ui dev",
    "build": "halo-ui build"
  }
}

参考文档

感谢阅读,欢迎交流与指正!

【读书笔记】UX for Business

最近看了一本名为 UX for Business 的书,这本书主要在讲设计师如何做有价值的事情。让我们跳出设计画板,在更高的层面理解日常工作。理解做什么,怎么做,怎么做到极致。但个人认为作者写得有些口语化,且不必要的内容重复过多。很多地方讲得比较浅,没有太多案例,推荐指数3颗星吧。

VDP 框架

作者强力推荐了 “VDP 框架” 来确保我们时刻在做正确、有效且对公司有价值的事情。

V for Value 价值

当你的设计同时满足了用户和公司的需求,你就在创造价值。

作者将用户需求分为两类,一类是效率类需求(Efficiency),另一类是娱乐类需求(Entertainment)。当设计师在面对效率优先的场景时,需要帮助用户用更少的时间、精力和钱来做事情,而不是更多。总的来说,就是让用户用更少的成本完成一件事。

举个例子:在烘培店里,如果一个新的烘培配方可以让烘培师用更少的鸡蛋做到和以前一样好吃,那么这就是一个好的配方设计。

聚焦能为用户/公司创造价值的地方:新增、购买、下载等流程。

D for Diagnosis 分析

指的是系统性地收集和分析问题,确保我们在做正确的事。作者在书中分享了做从设计分析到实施方案的思路。

1.了解背景

作者指出 UX 设计师的工作实际上是基于当前的信息和限制(如:业务指标、用户诉求、产品目标、、问题反馈、时间/资源限制等等)来产出解决方案来改进产品。

就像医生在看病时,会问病人有什么症状、有什么感觉、最近吃过什么、有没有对什么过敏等等,通过多种角度来下结论。

UX 设计师也要通过类似的方式来设计,想做出一个好的方案,就需要更多的信息。5W就是一个好的方法:

  • Who:用户是谁?他们的特征和需求是什么?
  • When:什么时候会有这个问题?
  • Where:在哪里会遇到这个问题?
  • What:能解决什么问题?
  • Why:为什么用户需要它?

2.发掘+分析问题

有时候我们收到一些产品体验的反馈,这可能只是一个问题的表象,仅仅解决它只是在打补丁,没有从根源性解决问题。

某个新功能的使用情况不理想,可能并非是该功能设计得不好,而是在上一层的漏斗出现问题。所以想要挖掘到深层次的根源问题,需要通过在多个问题之间来回推敲。

收集足够多的问题,为下一步做准备

3.找到问题共同点

将同类的问题归类好,利用前面的 5W 分析,比如将属于同一个 When 的问题归在一类,同一个 Who 的问题在另一类。

这种做法有时候会让我们不仅仅在做当前需求,还能帮助我们发掘到其他带改进的点。

4.规划改进问题

这一步就是将我们观察、收集到的信息转化为行动了。

前几步我们列出了问题的表象,并将其分类。这一步需要去思考什么引发了这一连串有关联性问题。比如一个页面的 Like 按钮点击量很少,但用户反馈中他们都很喜欢这里的内容,那我们就会假设是 Like 按钮做得不够明显,解决方案随之而来。

我们会进行不止一次的假设,按照可能性的高低来按顺序尝试解决方案。

5.验证结果

最后,将我们觉得可行的解决方案推进上线,基于效果决定是在当前解决方案下改进,还是换一个解决思路,甚至收集更多信息后再次进行设计。

P for Probability 可能性

利用 “可能性” 来优化设计,将价值最大化。作者通过分享了以下观点来阐述可能性在设计中的应用。

1.越靠前、越明显的内容具有更多的可能性。比如一组选项,第一个选项通常来说都是更大概率被选择的。所以,把重要的、能创造价值的内容做得更靠前和明显;

2.事情是线性发展的。你不能在完成第一件事之前完成第二件事,就好比我们在浏览一个导航的时候,如果我们找到了可能合适的跳转入口,我们就会直接点击,不会尝试去看后面的内容了;

3.用户在准备好做决定时才会去做决定。就好比电商场景,用户都会在足够了解产品之后,才决定是否购买,那么“购买”按钮通常就不会是页面上的第一个元素。

4.默认值能影响可能性。默认的值通常是最多人进行选择的,所以要思考怎么设计默认值才能提高用户使用效率,或者对公司有价值。

为内部系统做设计

为公司的内部系统有一个明显的特点:我们的用户不会流失。因为公司的雇员必须按照公司要求用某个系统完成工作,比如登记工作任务的工单系统、用于请假、报销的 OA 系统等等。

我们不需要考虑所谓的参与度、忠诚度,所以在用户价值/公司价值的取舍中,我们可以更多地倾向公司价值。

内部系统对效率的关注度会更高,因为不需要靠这个产品来赚钱。找到流程中的障碍并解决,是内部工具设计师的关键能力。

提升效率不光是对使用内部工具的员工有益,对公司的降低成本这种目标也是有价值的。高效率的工具可以让公司少雇佣一些雇员,帮助公司减少人力成本。作者甚至提到,如果内部工具能让小团队的生产效率提升得足够多,小公司也能和大公司竞争。

为商业化 SaaS 产品做设计

本书中提到一类产品属于 Sales-Driven 销售导向产品,对应到国内常用术语应该叫做商业化 SaaS 产品。

通常来说,做 SaaS 业务的公司通过卖这些获得收入:

  1. 席位:使用该产品的人数;
  2. 数据:使用服务的次数;
  3. 功能/模块:使用更强大功能的能力;

设计师需要清楚自己所负责的 SaaS 产品的收入来源,为公司所出售的服务进行设计。

共鸣片段

To improve conversion (i.e., get more people to finish your flow), think about everything you request from the user like a cost. Every question is a cost. Every minute is a cost

流程优化时,尝试将用户的每一步操作成本降到最低。鼠标移动、点击、鼠标滚轮、视线移动等等,都是操作成本。作者还提到一个观点,可以从最后一步开始优化,从后往前改进。因为在最后一步才导致转化失败会显得之前的努力都是徒劳。

UX is not what you do in Figma or a ticket in agile planning or the contrast of your button labels.
UX is a general process of designing things for humans.

我认为 UX 设计并不仅是 Figma 里一个个连接起来的页面,这只是表达清楚了页面/功能之间的跳转逻辑。UX 设计师是在为用户进行工作,需考虑到用户在流程中的使用所有体验,包括:

  • 浏览页面过程中的体验,比如信息层级设计,内容排版策略(例:恰当地设定文本换行规则);
  • 用户行为与系统交互中的体验,比如在进行增删改查时的功能引导、交互逻辑、操作反馈(例:校验表单的时机)、使用帮助、边缘场景闭环体验等;
  • 利用原生浏览器能力进行体验极致化,比如设置给 “版本号” 或 “错误代码” 的字段设置 user-select: all; 用户需要复制时,右键到文字区域就能自动全选该文本块,省去用鼠标框选的步骤;
  • 等等…

所以有时候做 “体验设计” 并不是在 Figma 里画图(这只是其中一种传达解决方案的形式),而是要提供一切能改进用户体验所需要的智慧结晶。

Design is a process, but not just a one-time process. We iterate!

设计是一个迭代过程,前面提到设计师是基于当前已知的信息和限制来提供解决方案。但世界上很多事情每天都在改变,产品目标会变,用户诉求会变,公司战略会变等等,所以每次迭代都是基于新的已知的信息进行改进。

Most importantly, what is the one thing the users should achieve on this page, if they only do one thing?

这句话提醒设计师,时刻将目标记在心头。当设计方案有陷入“既要又要”、“没有重点” 的困境时,拷问自己:在这个场景中,用户最重要的事情是什么?将最重要的事情做好。

sometimes the best design solutions require the designers and developers to do more work so users can do less.

这个深有同感,设计师和开发者费很大劲儿做出来的迭代,有时候只是帮助用户将使用流程更简化。

结语

第一次写读书笔记,改进的地方很多。但我会坚持阅读,纯粹觉得阅读能让我在这浮躁的社会,所谓的快节奏环境里慢下来。

❌