FIT2CLOUD

复用模板

Docsme 为了能够让使用者可以开箱即用,在插件中包含了默认的页面模板。但这种方式可能造成与网站的页面样式不兼容,比如顶部菜单栏无法复用。所以在最新的版本中(1.0.0-alpha.14),我们重构了默认主题的模板结构,可以让主题开发者更加方便地为文档页面提供模板。

在下面的文档中,我们会先列出所有可复用的模板,再讲解如何在自己开发的主题中使用。

模板列表

modules/doc.html

文档页面,包含最基本的文档结构。

点击查看模板源码
<th:block th:fragment="doc (footer)">
  <div class="dm-layout">
    <th:block th:replace="~{plugin:plugin-docsme:modules/sidebar}" />

    <main class="dm-main">
      <div class="dm-content">
        <th:block th:replace="~{plugin:plugin-docsme:modules/content-header}" />

        <article
          data-toc-content
          class="dm-content__body prose-content line-numbers"
          th:utext="${docInfo.content.content}"
        ></article>

        <div
          th:if="${haloCommentEnabled}"
          id="comment"
          class="dm-content__comment"
        >
          <halo:comment
            group="doc.halo.run"
            kind="DocTree"
            th:attr="name=${docTree.metadata.name}"
          />
        </div>

        <th:block th:replace="~{plugin:plugin-docsme:modules/navigation}" />

        <th:block th:if="${footer != null}">
          <th:block th:replace="${footer}" />
        </th:block>
      </div>

      <aside
        class="dm-toc"
        data-toc-container
        dm-data
        dm-bind:class="{'dm-toc--show' : $store.toc.open}"
      >
        <div class="dm-toc__header">
          <h2>目录</h2>
        </div>
        <div class="dm-toc__body" data-toc-list></div>
      </aside>
      <div
        class="dm-toc-backdrop"
        dm-show="$store.toc.open"
        dm-on:click="$store.toc.open = false"
      ></div>
    </main>
  </div>
</th:block>

modules/doc-catalog.html

文档目录页面。

点击查看模板源码
<th:block th:fragment="doc-catalog (footer)">
  <div class="dm-layout">
    <th:block th:replace="~{plugin:plugin-docsme:modules/sidebar}" />

    <main class="dm-main">
      <div class="dm-content">
        <th:block th:replace="~{plugin:plugin-docsme:modules/content-header}" />

        <article class="dm-content__body">
          <div class="dm-directory-grid">
            <a
              th:each="node : ${sonNodes}"
              th:href="${node.status.permalink}"
              class="dm-directory-card"
            >
              <div
                class="dm-directory-card__icon"
                th:text="${node.spec.type == 'TREE' ? '📁' : '📝'}"
              >
                {{ item.icon }}
              </div>
              <h3 class="dm-directory-card__title" th:text="${node.spec.title}">
                {{ item.name }}
              </h3>
            </a>
          </div>
        </article>

        <th:block th:replace="~{plugin:plugin-docsme:modules/navigation}" />

        <th:block th:if="${footer != null}">
          <th:block th:replace="${footer}" />
        </th:block>
      </div>
    </main>
  </div>
</th:block>

modules/docs.html

文档项目列表页面。

点击查看模板源码
<th:block th:fragment="docs (footer,showHeader,showThemeSwitcher)">
  <header th:if="${showHeader}" class="dm-header">
    <div class="dm-header__content">
      <div class="dm-header__logo">
        <img th:unless="${#strings.isEmpty(site.logo)}" th:src="${site.logo}" />
        <a th:href="${site.url}" th:text="${site.title}"></a>
      </div>
      <nav class="dm-header__nav">
        <button
          th:if="${showThemeSwitcher}"
          class="dm-header__button"
          data-theme-switcher
        >
          <span
            class="theme-icon theme-icon--light icon-[mingcute--brightness-line]"
          ></span>
          <span
            class="theme-icon theme-icon--dark icon-[mingcute--moon-line]"
          ></span>
        </button>
      </nav>
    </div>
  </header>

  <main class="dm-main-content">
    <div class="dm-projects-header">
      <h1 class="dm-projects-header__title">文档</h1>
      <p class="dm-projects-header__description">所有文档项目</p>
    </div>

    <div class="dm-projects-grid">
      <a
        th:each="project : ${projects}"
        th:href="${project.status.permalink}"
        class="dm-project-card"
      >
        <div class="dm-project-card__banner">
          <div
            th:unless="${#strings.isEmpty(project.spec.icon)}"
            class="dm-project-card__icon"
          >
            <img
              th:src="${project.spec.icon}"
              th:alt="${project.spec.displayName}"
            />
          </div>
          <div
            th:if="${#strings.isEmpty(project.spec.icon)}"
            class="dm-project-card__icon"
          >
            📚
          </div>
        </div>
        <div class="dm-project-card__content">
          <h3
            class="dm-project-card__title"
            th:text="${project.spec.displayName}"
          ></h3>
          <ul class="dm-project-card__info">
            <li th:text="|共 ${project.status.totalDocs ?: 0} 篇文档|"></li>
            <li th:text="${project.status.permalink}"></li>
          </ul>
          <div class="dm-project-card__actions">
            <span class="dm-project-card__button">访问项目</span>
          </div>
        </div>
      </a>
    </div>

    <th:block th:if="${footer != null}">
      <th:block th:replace="${footer}" />
    </th:block>
  </main>
</th:block>

modules/content-header.html

文档内容顶部模块,包含面包屑和在移动端展开文档树和目录的按钮。

点击查看模板源码
<header class="dm-content__header">
  <nav class="dm-content__breadcrumb">
    <a th:href="${project.status.permalink}">首页</a>
    <a th:each="crumb : ${crumbs}" th:href="${crumb.status.permalink}" th:text="${crumb.spec.title}"></a>
  </nav>
  <div class="dm-content__controls">
    <button dm-data dm-on:click="$store.sidebar.open = !$store.sidebar.open">
      <span class="icon-[mingcute--menu-line]"></span>
      菜单
    </button>
    <button dm-data th:if="${docTree.spec.type == 'DOC'}" dm-on:click="$store.toc.open = !$store.toc.open">
      <span class="icon-[mingcute--list-ordered-line]"></span>
      本页目录
    </button>
  </div>
</header>

modules/doc-tree.html

左侧文档树模块。

点击查看模板源码
<details
  th:attr="open=${#arrays.contains(crumbs, parentDocTree) || currentDocTree.metadata.name == docTree.metadata.name}"
  th:id="${parentDocTree.metadata.name}"
  th:fragment="next (parentDocTree,docTrees)"
>
  <summary class="dm-nav-tree__toggle">
    <a
      th:href="${parentDocTree.status.permalink}"
      th:text="${parentDocTree.spec.title}"
      class="dm-nav-tree__link"
      th:classappend="${currentDocTree.metadata.name == docTree.metadata.name ? 'dm-nav-tree__link--active' : ''}"
    ></a>
  </summary>
  <ul class="dm-nav-tree dm-nav-tree__nested">
    <li class="dm-nav-tree__item" th:fragment="single (docTrees)" th:each="currentDocTree : ${docTrees}">
      <a
        class="dm-nav-tree__link"
        th:if="${currentDocTree.spec.type == 'DOC'}"
        th:href="@{${currentDocTree.status.permalink}}"
        th:title="${currentDocTree.spec.title}"
        th:classappend="${currentDocTree.metadata.name == docTree.metadata.name ? 'dm-nav-tree__link--active' : ''}"
        th:text="${currentDocTree.spec.title}"
      >
      </a>
      <th:block th:if="${currentDocTree.spec.type == 'TREE'}">
        <th:block
          th:replace="~{plugin:plugin-docsme:modules/doc-tree :: next (parentDocTree=${currentDocTree},docTrees=${currentDocTree.children})}"
        ></th:block>
      </th:block>
    </li>
  </ul>
</details>

modules/header.html

顶部菜单栏模块,包含文档 Logo、名称、文档版本切换按钮、语言切换按钮、明暗主题切换按钮。

点击查看模板源码
<th:block th:fragment="header(showThemeSwitcher)">
  <header class="dm-header">
    <div class="dm-header__content">
      <div class="dm-header__logo">
        <img
          th:unless="${#strings.isEmpty(project.spec.icon)}"
          th:src="${project.spec.icon}"
        />
        <a
          th:href="${project.status.permalink}"
          th:text="${project.spec.displayName}"
        ></a>
      </div>
      <nav class="dm-header__nav">
        <div
          th:if="${#lists.size(versions) gt 1}"
          dm-data="{
                          open: false,
                          toggle() {
                              if (this.open) {
                                  return this.close()
                              }
                              this.$refs.button.focus()
                              this.open = true
                          },
                          close(focusAfter) {
                              if (! this.open) return
              
                              this.open = false
                              focusAfter && focusAfter.focus()
                          }
                      }"
          dm-on:keydown.escape.prevent.stop="close($refs.button)"
          dm-on:focusin.window="$refs.panel && ! $refs.panel.contains($event.target) && close()"
          dm-id="['version-switcher']"
          class="dm-header__switcher"
        >
          <!-- Button -->
          <div
            dm-ref="button"
            dm-on:click="toggle()"
            :aria-expanded="open"
            :aria-controls="$id('version-switcher')"
            class="dm-header__switcher-button"
          >
            <span th:text="${currentVersion.spec.slug}"></span>
            <span
              class="dm-header__switcher-icon icon-[mingcute--down-line]"
            ></span>
          </div>
          <div
            dm-ref="panel"
            dm-show="open"
            dm-transition.origin.top.left
            dm-on:click.outside="close($refs.button)"
            :id="$id('version-switcher')"
            dm-cloak
            th:attr="dm-data=|{ currentVersionLink : '${currentVersion.status.permalink}' }|"
            class="dm-header__switcher-panel"
          >
            <a
              th:attr="dm-data=|{ versionLink : '${version.status.permalink}' }|"
              th:each="version : ${versions}"
              dm-bind:href="location.pathname.replace(currentVersionLink, versionLink)"
              th:text="${version.spec.slug}"
            >
            </a>
          </div>
        </div>

        <div
          th:if="${#lists.size(languages) gt 1}"
          dm-data="{
                          open: false,
                          toggle() {
                              if (this.open) {
                                  return this.close()
                              }
                              this.$refs.button.focus()
                              this.open = true
                          },
                          close(focusAfter) {
                              if (! this.open) return
              
                              this.open = false
                              focusAfter && focusAfter.focus()
                          }
                      }"
          dm-on:keydown.escape.prevent.stop="close($refs.button)"
          dm-on:focusin.window="$refs.panel && ! $refs.panel.contains($event.target) && close()"
          dm-id="['language-switcher']"
          class="dm-header__switcher"
        >
          <!-- Button -->
          <div
            dm-ref="button"
            dm-on:click="toggle()"
            :aria-expanded="open"
            :aria-controls="$id('language-switcher')"
            class="dm-header__switcher-button"
          >
            <span
              th:if="${currentLanguage != null}"
              th:text="${currentLanguage.label}"
            ></span>
            <span
              th:if="${currentLanguage == null}"
              class="icon-[mingcute--translate-2-line]"
            ></span>
            <span
              class="dm-header__switcher-icon icon-[mingcute--down-line]"
            ></span>
          </div>
          <div
            dm-ref="panel"
            dm-show="open"
            dm-transition.origin.top.left
            dm-on:click.outside="close($refs.button)"
            :id="$id('language-switcher')"
            dm-cloak
            th:attr="dm-data=|{ currentLanguageLink : '${currentLanguage.link}' }|"
            class="dm-header__switcher-panel"
          >
            <a
              th:attr="dm-data=|{ languageLink : '${language.link}' }|"
              th:each="language : ${languages}"
              dm-bind:href="location.pathname.replace(currentLanguageLink,languageLink)"
            >
              <span th:text="${language.language}"></span>
              <span th:text="${language.label}"></span>
            </a>
          </div>
        </div>
        <button
          th:if="${pluginFinder.available('PluginSearchWidget')}"
          id="btn-search"
          class="dm-header__button"
          title="搜索"
        >
          <span class="icon-[mingcute--search-line]"></span>
        </button>
        <button
          th:if="${showThemeSwitcher}"
          class="dm-header__button"
          data-theme-switcher
        >
          <span
            class="theme-icon theme-icon--light icon-[mingcute--brightness-line]"
          ></span>
          <span
            class="theme-icon theme-icon--dark icon-[mingcute--moon-line]"
          ></span>
        </button>
      </nav>
    </div>
  </header>
</th:block>

modules/navigation.html

文档底部上一篇/下一篇按钮。

点击查看模板源码
<nav th:if="${linkNavigation}" class="dm-doc-nav">
  <a
    th:if="${linkNavigation.hasPrevious}"
    th:href="${linkNavigation.previous.link}"
    class="dm-doc-nav__item dm-doc-nav__item--prev"
  >
    <span class="dm-doc-nav__icon icon-[mingcute--right-line]"></span>
    <div class="dm-doc-nav__content">
      <div class="dm-doc-nav__label">上一篇</div>
      <h3 class="dm-doc-nav__title" th:text="${linkNavigation.previous.title}"></h3>
    </div>
  </a>
  <a
    th:if="${linkNavigation.hasNext}"
    th:href="${linkNavigation.next.link}"
    class="dm-doc-nav__item dm-doc-nav__item--next"
  >
    <span class="dm-doc-nav__icon icon-[mingcute--right-line]"></span>
    <div class="dm-doc-nav__content">
      <div class="dm-doc-nav__label">下一篇</div>
      <h3 class="dm-doc-nav__title" th:text="${linkNavigation.next.title}"></h3>
    </div>
  </a>
</nav>

modules/plugin-scripts.html

插件适配脚本,包含搜索、文本绘图KaTeX

点击查看模板源码
<script th:if="${pluginFinder.available('PluginSearchWidget')}" th:inline="javascript">
  document.addEventListener("DOMContentLoaded", () => {
    const btnSearch = document.getElementById("btn-search");
    if (btnSearch) {
      btnSearch.addEventListener("click", () => {
        const categoryNames = {
          project: "[(${project.metadata.name})]",
          version: "[(${currentVersion.metadata.name})]",
          language: "[(${currentLanguage.language})]",
        };

        const options = {
          includeCategoryNames: Object.entries(categoryNames)
            .map(([key, value]) => {
              if (value) {
                return `${key}:${value}`;
              }
            })
            .filter(Boolean),
        };
        SearchWidget.open(options);
      });
    }
  });
</script>

<th:block th:if="${pluginFinder.available('text-diagram')} and ${docTree.spec.type == 'DOC'}">
  <script defer src="/plugins/text-diagram/assets/static/mermaid.min.js"></script>
  <script>
    document.addEventListener("DOMContentLoaded", function () {
      const isDark = document.documentElement.dataset.theme === "dark";
      mermaid.initialize({
        startOnLoad: false,
        theme: isDark ? "dark" : "default",
      });
      mermaid.run({
        querySelector: "#content text-diagram[data-type=mermaid]",
      });
    });
  </script>
</th:block>

<th:block th:if="${pluginFinder.available('plugin-katex')} and ${docTree.spec.type == 'DOC'}">
  <link rel="stylesheet" href="/plugins/plugin-katex/assets/static/katex.min.css" />
  <script defer src="/plugins/plugin-katex/assets/static/katex.min.js"></script>
  <script>
    document.addEventListener("DOMContentLoaded", function () {
      const body = document.getElementById("content");
      const renderMath = (selector, displayMode) => {
        const els = body.querySelectorAll(selector);
        els.forEach((el) => {
          katex.render(el.innerText, el, { displayMode });
        });
      };
      if (body) {
        renderMath("[math-inline]", false);
        renderMath("[math-display]", true);
      }
    });
  </script>
</th:block>

modules/prism.html

默认代码高亮插件的样式和脚本文件,如果你需要使用其他代码高亮插件,可以不引入。

点击查看模板源码
<link
  rel="stylesheet"
  th:href="@{/plugins/plugin-docsme/assets/static/libs/prism/prism.css?v={version}(version=${plugin.version})}"
/>
<script
  th:src="@{/plugins/plugin-docsme/assets/static/libs/prism/prism.js?v={version}(version=${plugin.version})}"
></script>

modules/script.html

基本的文档页面交互脚本,包括明暗主题切换、文档目录生成。

点击查看模板源码
<script
  th:src="@{/plugins/plugin-docsme/assets/static/dist/main.iife.js?v={version}(version=${plugin.version})}"
></script>

modules/sidebar.html

文档页面侧边栏,主要包含文档目录树。

点击查看模板源码
<aside
  class="dm-sidebar"
  dm-data
  dm-bind:class="{'dm-sidebar--show' : $store.sidebar.open}"
>
  <div class="dm-sidebar__content">
    <ul class="dm-nav-tree">
      <li
        th:replace="~{plugin:plugin-docsme:modules/doc-tree :: single(docTrees=${docTrees})}"
      />
    </ul>
  </div>
</aside>
<div
  class="dm-sidebar-backdrop"
  dm-show="$store.sidebar.open"
  dm-on:click="$store.sidebar.open = false"
></div>

modules/style.html

基本的文档样式文件,如果你需要完全定制文档页面的样式,可以根据页面的 dom 结构自行编写样式。

点击查看模板源码
<link
  rel="stylesheet"
  th:href="@{/plugins/plugin-docsme/assets/static/dist/main.css?v={version}(version=${plugin.version})}"
/>

主题模板编写

文档项目列表页面

在主题中创建一个名为 docs.html 的模板,最基础的示例如下:

<th:block th:replace="~{plugin:plugin-docsme:modules/style}" />
<th:block th:replace="~{plugin:plugin-docsme:modules/script}" />
<div class="dm-container">
  <th:block th:replace="~{plugin:plugin-docsme:modules/docs :: docs (footer = null, showHeader = true)}" />
</div>

文档页面

在主题中创建一个名为 doc.html 的模板,最基础的示例如下:

<th:block th:replace="~{plugin:plugin-docsme:modules/style}" />
<th:block th:replace="~{plugin:plugin-docsme:modules/script}" />
<th:block th:replace="~{plugin:plugin-docsme:modules/prism}" />
<th:block th:replace="~{plugin:plugin-docsme:modules/plugin-scripts}" />
<div class="dm-container">
  <th:block th:replace="~{plugin:plugin-docsme:modules/header :: header (showThemeSwitcher = false)}" />
  <th:block th:replace="~{plugin:plugin-docsme:modules/doc :: doc(footer = null)}" />
</div>

文档目录页面

在主题中创建一个名为 doc-catalog.html 的模板,最基础的示例如下:

<th:block th:replace="~{plugin:plugin-docsme:modules/style}" />
<th:block th:replace="~{plugin:plugin-docsme:modules/script}" />
<th:block th:replace="~{plugin:plugin-docsme:modules/plugin-scripts}" />
<div class="dm-container">
  <th:block th:replace="~{plugin:plugin-docsme:modules/header :: header (showThemeSwitcher = false)}" />
  <th:block th:replace="~{plugin:plugin-docsme:modules/doc-catalog :: doc-catalog(footer = null)}" />
</div>

以上只是最基本的模板结构,如果你要与主题布局相结合,那么需要自行进行调整,比如在各个模板外层引入 layout.html 布局模板,假设你的 layout.html 模板为:

<!DOCTYPE html>
<html lang="en" xmlns:th="https://www.thymeleaf.org" th:fragment="html (head,content)">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2" />
    <title th:text="${site.title}"></title>
    <link rel="stylesheet" th:href="@{/assets/dist/style.css}" />
    <script th:src="@{/assets/dist/main.iife.js}"></script>
    <th:block th:if="${head != null}">
      <th:block th:replace="${head}" />
    </th:block>
  </head>
  <body>
    <section>
      <th:block th:replace="${content}" />
    </section>
  </body>
</html>

那么你的 doc.html 模板就可以这样写:

<!DOCTYPE html>
<html
  xmlns:th="https://www.thymeleaf.org"
  th:replace="~{modules/layout :: html(head = ~{::head},content = ~{::content})}"
>
  <th:block th:fragment="head">
    <th:block th:replace="~{plugin:plugin-docsme:modules/style}" />
    <th:block th:replace="~{plugin:plugin-docsme:modules/script}" />
    <th:block th:replace="~{plugin:plugin-docsme:modules/prism}" />
    <th:block th:replace="~{plugin:plugin-docsme:modules/plugin-scripts}" />
  </th:block>
  <th:block th:fragment="content">
    <div class="dm-container">
      <th:block
        th:replace="~{plugin:plugin-docsme:modules/header :: header (showThemeSwitcher = false)}"
      />
      <th:block
        th:replace="~{plugin:plugin-docsme:modules/doc :: doc(footer = null)}"
      />
    </div>
  </th:block>
</html>

Finder API

注意

此 API 在 1.4.0 提供,如果主题需要使用这个 API,建议在模板使用的地方判断插件版本是否符合要求,避免报错。

判断方式可查阅:https://docs.halo.run/developer-guide/theme/finder-apis/plugin#availablepluginname-requiresversion

Docsme 插件提供了 docsmeProjectsFinder Finder,可在 Thymeleaf 模板中直接调用。

docsmeProjectsFinder.listAll()

返回当前用户有权访问的所有文档项目列表(Flux<Project>),按创建时间降序、名称降序排列。私有项目(authorizeRequired = true)会自动根据当前用户权限过滤。

Thymeleaf 用法示例:

<th:block th:each="project : ${docsmeProjectsFinder.listAll()}">
  <a th:href="${project.status.permalink}" th:text="${project.spec.displayName}"></a>
</th:block>

docsmeProjectsFinder.list(page, pageSize)

返回当前用户有权访问的文档项目分页列表(Mono<ListResult<Project>>),按创建时间降序、名称降序排列。私有项目(authorizeRequired = true)会自动根据当前用户权限过滤。

参数

类型

说明

page

Integer

页码,从 1 开始,传 null 时默认为 1

pageSize

Integer

每页条数,传 null 时默认为 10

Thymeleaf 用法示例:

<th:block th:with="result=${docsmeProjectsFinder.list(1, 10)}">
  <th:block th:each="project : ${result.items}">
    <a th:href="${project.status.permalink}" th:text="${project.spec.displayName}"></a>
  </th:block>
  <span th:text="|共 ${result.total} 个项目|"></span>
</th:block>

类型

Project

字段

类型

说明

metadata.name

String

项目唯一标识

metadata.creationTimestamp

Instant

创建时间

spec.displayName

String

项目显示名称

spec.description

String

项目描述

spec.owner

String

项目所有者用户名

spec.slug

String

项目 slug,用于构建访问路径

spec.icon

String

项目图标 URL

spec.enableComment

boolean

是否开启评论

spec.authorizeRequired

boolean

是否需要授权才能访问

spec.preferredVersionRef.name

String

首选版本的名称

spec.preferredLanguage

String

首选语言的 slug

spec.members

List<ProjectMember>

项目成员列表

status.totalDocs

int

项目下已发布的文档总数

status.permalink

String

项目访问链接

ProjectMember

字段

类型

说明

name

String

成员名称(用户名或角色名)

kind

MemberType

成员类型:USERROLE

permission

PermissionLevel

权限级别:READWRITEADMIN