Skip to content

Zensical 再現手順

会社の Claude に、このページの「指示文」をそのまま渡す。 解釈や改善はさせず、書いてあるファイルだけ作らせる。

単一リポジトリ向けである。 docs-hub のような複数リポジトリ集約は含めない。

Zensical 本体に図の拡大はない。 虫眼鏡は docs/stylesheets/mermaid.css と docs/javascripts/mermaid-zoom.js で足している。 横幅は docs/stylesheets/layout.css で広げている。 指示文にこれらを含めている。

フォルダ構成の意図

かつては 構成(overview/)と 手順(runbooks/)の2系統だけだった。 これだと「何を言えばいいか」「データはどこから入るか」「長い SQL やアーカイブはどこか」が、どちらにも収まりにくかった。 ページを足すたびに入口が肥大し、読者は自分の問いに合う場所を探しづらくなる。

いまは問いごとに系統を分ける。

系統 ディレクトリ 読者の問い
使い方 guide/ 何をすればよいか。用語や画面の意味は何か
アーキテクチャ architecture/ どう動くか。何が正本か
取込 intake/ データやイベントはどこから入るか(入口があるプロジェクトだけ)
運用 ops/ いま何の作業をするか。コマンドは何か
参照 reference/ 長い定義、SQL、外部 URL、アーカイブはどこか

分け方の基準は「情報の種類」ではなく「読者が今持っている問い」である。 仕組みの説明を運用ページに書かない。 手順をアーキテクチャページに書かない。 両方必要なときは、それぞれ短く書いて相互リンクする。

intake/ と reference/ は必須ではない。 入口が無いプロジェクト(例: pve-infra)では intake/ を置かない。 長い参照物が無いときは reference/ を薄くするか、外部リンク1枚だけにする。

ナビは zensical.toml にページ一覧を書かず、フォルダから自動生成する。 系統の名前とフォルダ名がそのまま左ナビの親になる。 集約サイト(docs-hub)でも、finance-manager / pve-infra / home-kitchen / docs-hub が同じ問いの地図を共有できる。

検索タグも系統名とリポジトリ名だけにする。 内容トピック(Discord、Cloudflare など)をタグにすると、フィルターが増えて逆に探しにくくなるためである。

指示文

以下をコピーして渡す。

あなたは Zensical の設定だけを再現する。推測で機能を足さない。
書いていない feature / extension / テンプレート上書きは作らない。
nav は zensical.toml に書かない(フォルダから自動生成させる)。
navigation.sections は使わない(タブと併用するとセクションの > が消える)。
variant は書かない(デフォルトの modern を使う)。

# フォルダ

docs/
  index.md
  javascripts/
    search-filters-open.js
    mermaid-zoom.js
  stylesheets/
    layout.css
    mermaid.css
  guide/
    index.md
    (使い方のページ)
  architecture/
    index.md
    (仕組みのページ)
  intake/          # データ入口があるプロジェクトだけ
    index.md
  ops/
    index.md
    (手順のページ)
  reference/       # 長い参照・アーカイブ
    index.md

zensical.toml はリポジトリルート。

フォルダ分けの意図(守ること):
- 読者の問いで分ける。情報の種類では分けない。
- guide = 何をすればよいか / 用語の意味。architecture = どう動くか / 何が正本か。
- intake = データの入口(無いプロジェクトでは作らない)。ops = 作業手順とコマンド。
- reference = 長い定義・SQL・外部 URL・アーカイブ。入口の説明はここへ逃がさない。
- 仕組みを ops に書かない。手順を architecture に書かない。両方要るときは短く書いて相互リンク。
- かつての overview(構成)/ runbooks(手順)の2系統には戻さない。問いが足りなかったためである。
- nav はフォルダから自動生成されるので、フォルダ名がそのままナビの親になる。

# アイコン

各 Markdown の frontmatter に icon を書く。
- docs/index.md → lucide/library
- docs/guide/index.md → lucide/book-open
- docs/architecture/index.md → lucide/boxes
- docs/ops/index.md → lucide/wrench
- docs/reference/index.md → lucide/library
- 個別ページ → 内容が分かる lucide アイコン(index 用アイコンの連打はしない)

frontmatter 例:

---
title: 使い方
description: (1行)
tags:
  - 使い方
  - <リポジトリ名>
icon: lucide/book-open
---

ops の tags は `- 運用`。architecture は `- アーキテクチャ`。
すべてのページにリポジトリ名タグを付ける(例: `- finance-manager`)。
検索フィルターは系統とリポジトリ名だけにする。内容トピックは付けない。

# zensical.toml

次をそのまま使う。山括弧だけ置き換える。

```toml
[project]
site_name = "<サイト名>"
site_url = "<公開URL>"
site_description = "<1行説明>"
docs_dir = "docs"
site_dir = "site"
extra_css = [
  "stylesheets/layout.css",
  "stylesheets/mermaid.css",
]
extra_javascript = [
  "javascripts/search-filters-open.js",
  "javascripts/mermaid-zoom.js",
]
repo_url = "https://github.com/<org>/<repo>"
repo_name = "<org>/<repo>"
edit_uri = "edit/main/docs/"

[project.theme]
features = [
  "navigation.indexes",
  "navigation.tabs",
  "navigation.tabs.sticky",
  "navigation.path",
  "navigation.top",
  "navigation.instant",
  "navigation.instant.prefetch",
  "navigation.instant.progress",
  "navigation.tracking",
  "toc.follow",
  "search.highlight",
  "search.suggest",
  "content.code.copy",
  "content.code.select",
  "content.action.edit",
]

[project.theme.icon]
repo = "fontawesome/brands/github"
edit = "material/file-edit-outline"

[[project.theme.palette]]
media = "(prefers-color-scheme)"
primary = "blue"
accent = "blue"
toggle.icon = "lucide/sun-moon"
toggle.name = "ライトモードに切り替え"

[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "blue"
accent = "blue"
toggle.icon = "lucide/sun"
toggle.name = "ダークモードに切り替え"

[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "blue"
accent = "blue"
toggle.icon = "lucide/moon"
toggle.name = "システム設定に合わせる"

[project.markdown_extensions.admonition]
[project.markdown_extensions.pymdownx.details]
[project.markdown_extensions.pymdownx.inlinehilite]
[project.markdown_extensions.pymdownx.superfences]
custom_fences = [
  { name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" },
]

[project.markdown_extensions.pymdownx.highlight]
anchor_linenums = true
line_spans = "__span"
pygments_lang_class = true

[project.markdown_extensions.toc]
permalink = true
permalink_title = "この見出しへのリンク"
```

# docs/javascripts/search-filters-open.js

次のファイルをそのまま置く。検索を開いたときタグフィルターを最初から出す。

```javascript
/* 検索を開いたとき、タグフィルターを最初から表示する。 */
(function () {
  var openedThisSession = false
  var timers = []

  function filtersVisible() {
    return Array.prototype.some.call(document.querySelectorAll("h3"), function (el) {
      return el.textContent.trim() === "Filters" && el.offsetParent !== null
    })
  }

  function filterButton() {
    var root = document.querySelector("[data-md-component=search]")
    if (!root) return null
    var buttons = Array.prototype.filter.call(root.querySelectorAll("button"), function (btn) {
      return !btn.classList.contains("md-search__button")
    })
    return buttons.length ? buttons[buttons.length - 1] : null
  }

  function ensureOpen() {
    if (filtersVisible()) return
    var btn = filterButton()
    if (btn) btn.click()
  }

  function startSession() {
    if (openedThisSession) return
    openedThisSession = true
    ;[0, 50, 200, 400].forEach(function (ms) {
      timers.push(setTimeout(ensureOpen, ms))
    })
  }

  function endSession() {
    openedThisSession = false
    timers.forEach(clearTimeout)
    timers = []
  }

  function bind() {
    var toggle = document.getElementById("__search")
    if (!toggle || toggle.dataset.filtersDefault === "1") return
    toggle.dataset.filtersDefault = "1"
    toggle.addEventListener("change", function () {
      if (toggle.checked) startSession()
      else endSession()
    })
  }

  if (window.document$) {
    document$.subscribe(bind)
  } else {
    document.addEventListener("DOMContentLoaded", bind)
  }
})()
```

# docs/stylesheets/layout.css

次のファイルをそのまま置く。コンテンツの横幅を広げる。

```css
/* 既定は約 61rem。本文と表、図が窮屈なので広げる。 */
.md-grid {
  max-width: 90rem;
}
```

# docs/stylesheets/mermaid.css

次のファイルをそのまま置く。Mermaid 図の右上に、コードコピーと同じ系統の虫眼鏡を出す。
Zensical 本体に図の拡大はない。SVG は閉じた Shadow DOM に入る。

```css
/* コードブロックのコピーボタンと同じ位置と色にする。 */
.md-typeset .mermaid-frame {
  position: relative;
  margin: 1em 0;
}

.md-typeset .mermaid-frame > .mermaid {
  margin: 0;
  overflow: auto;
  max-height: min(80vh, 48rem);
}

.md-typeset .mermaid-zoom-btn {
  position: absolute;
  top: 0.4rem;
  right: 0.4rem;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.8rem;
  height: 1.8rem;
  padding: 0;
  border: 0;
  border-radius: 0.1rem;
  background: color-mix(in srgb, var(--md-default-bg-color) 82%, transparent);
  color: var(--md-default-fg-color--lightest);
  cursor: zoom-in;
}

.md-typeset .mermaid-frame:hover .mermaid-zoom-btn,
.md-typeset .mermaid-zoom-btn:focus-visible {
  color: var(--md-default-fg-color--light);
}

.md-typeset .mermaid-zoom-btn:hover {
  color: var(--md-accent-fg-color);
}

.md-typeset .mermaid-zoom-btn svg {
  width: 1.05rem;
  height: 1.05rem;
  fill: currentColor;
}

html.mermaid-lightbox-open {
  overflow: hidden;
}

.mermaid-lightbox {
  position: fixed;
  inset: 0;
  z-index: 400;
  background: color-mix(in srgb, var(--md-default-bg-color) 94%, transparent);
}

.mermaid-lightbox__close {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.8rem;
  height: 1.8rem;
  padding: 0;
  border: 0;
  border-radius: 0.1rem;
  background: transparent;
  color: var(--md-default-fg-color--light);
  cursor: pointer;
}

.mermaid-lightbox__close:hover {
  color: var(--md-accent-fg-color);
}

.mermaid-lightbox__close svg {
  width: 1.1rem;
  height: 1.1rem;
  fill: currentColor;
}

.mermaid-lightbox__stage {
  position: absolute;
  inset: 0;
  overflow: hidden;
  cursor: grab;
}

.mermaid-lightbox__stage.is-dragging {
  cursor: grabbing;
}

.mermaid-lightbox__stage .mermaid {
  display: inline-block;
}
```

# docs/javascripts/mermaid-zoom.js

次のファイルをそのまま置く。描画後のホストを拡大ビューへ移す。
ラベルは出さない。title は「拡大」と「閉じる」だけにする。

```javascript
/* Mermaid は閉じた Shadow DOM のため、描画後のホストを拡大ビューへ移す。 */
(function () {
  var MAGNIFY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79L20.5 19 19 20.5l-4.79-4.79v-.79l-.27-.27A6.5 6.5 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5z"/></svg>'
  var CLOSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>'

  var overlay = null
  var home = null
  var timers = []
  var observer = null
  var dragMove = null
  var dragUp = null

  function clearTimers() {
    timers.forEach(clearTimeout)
    timers = []
  }

  function isHost(el) {
    return el && el.nodeType === 1 && el.classList.contains("mermaid") && el.tagName === "DIV"
  }

  function restoreHost() {
    if (!home) return
    home.host.style.transform = ""
    home.host.style.transformOrigin = ""
    home.parent.insertBefore(home.host, home.next)
    home = null
  }

  function unbindDrag() {
    if (dragMove) window.removeEventListener("mousemove", dragMove)
    if (dragUp) window.removeEventListener("mouseup", dragUp)
    dragMove = null
    dragUp = null
  }

  function closeLightbox() {
    restoreHost()
    if (!overlay) return
    overlay.remove()
    overlay = null
    document.documentElement.classList.remove("mermaid-lightbox-open")
    document.removeEventListener("keydown", onKey)
    unbindDrag()
  }

  function onKey(event) {
    if (event.key === "Escape") closeLightbox()
  }

  function iconButton(svg, className, label) {
    var btn = document.createElement("button")
    btn.type = "button"
    btn.className = className
    btn.title = label
    btn.setAttribute("aria-label", label)
    btn.innerHTML = svg
    return btn
  }

  function openLightbox(host) {
    closeLightbox()

    home = {
      host: host,
      parent: host.parentNode,
      next: host.nextSibling
    }

    overlay = document.createElement("div")
    overlay.className = "mermaid-lightbox"
    overlay.setAttribute("role", "dialog")
    overlay.setAttribute("aria-modal", "true")
    overlay.setAttribute("aria-label", "図の拡大表示")

    var closeBtn = iconButton(CLOSE, "md-icon mermaid-lightbox__close", "閉じる")
    closeBtn.addEventListener("click", closeLightbox)

    var stage = document.createElement("div")
    stage.className = "mermaid-lightbox__stage"
    stage.appendChild(host)

    overlay.appendChild(closeBtn)
    overlay.appendChild(stage)
    document.body.appendChild(overlay)
    document.documentElement.classList.add("mermaid-lightbox-open")
    document.addEventListener("keydown", onKey)
    closeBtn.focus()

    var scale = 1.4
    var x = 24
    var y = 24
    var dragging = false
    var lastX = 0
    var lastY = 0

    function apply() {
      host.style.transform = "translate(" + x + "px, " + y + "px) scale(" + scale + ")"
      host.style.transformOrigin = "0 0"
    }

    apply()

    stage.addEventListener("wheel", function (event) {
      event.preventDefault()
      var next = scale * (event.deltaY < 0 ? 1.12 : 0.89)
      scale = Math.min(8, Math.max(0.25, next))
      apply()
    }, { passive: false })

    stage.addEventListener("mousedown", function (event) {
      if (event.button !== 0) return
      dragging = true
      stage.classList.add("is-dragging")
      lastX = event.clientX
      lastY = event.clientY
    })

    dragMove = function (event) {
      if (!dragging) return
      x += event.clientX - lastX
      y += event.clientY - lastY
      lastX = event.clientX
      lastY = event.clientY
      apply()
    }
    dragUp = function () {
      dragging = false
      stage.classList.remove("is-dragging")
    }
    window.addEventListener("mousemove", dragMove)
    window.addEventListener("mouseup", dragUp)
  }

  function enhance() {
    document.querySelectorAll("div.mermaid").forEach(function (el) {
      if (!isHost(el)) return
      if (el.closest(".mermaid-lightbox")) return
      if (el.dataset.zoomBound === "1") return
      el.dataset.zoomBound = "1"

      var frame = document.createElement("div")
      frame.className = "mermaid-frame"
      el.parentNode.insertBefore(frame, el)
      frame.appendChild(el)

      var btn = iconButton(MAGNIFY, "md-icon mermaid-zoom-btn", "拡大")
      btn.addEventListener("click", function () {
        openLightbox(el)
      })
      frame.appendChild(btn)
    })
  }

  function watch() {
    if (observer) observer.disconnect()
    var root = document.querySelector("article") || document.querySelector(".md-content") || document.body
    observer = new MutationObserver(enhance)
    observer.observe(root, { childList: true, subtree: true })
  }

  function bind() {
    closeLightbox()
    clearTimers()
    enhance()
    watch()
    ;[50, 150, 400, 800, 1600, 3200].forEach(function (ms) {
      timers.push(setTimeout(enhance, ms))
    })
  }

  function patchMermaidConfig() {
    if (typeof window.mermaid === "undefined" || window.mermaid instanceof Element) return false
    if (window.mermaid.__docsHubPatched) return true
    if (typeof window.mermaid.initialize !== "function") return false
    var orig = window.mermaid.initialize.bind(window.mermaid)
    window.mermaid.initialize = function (config) {
      config = config || {}
      orig(Object.assign({}, config, {
        er: Object.assign({ useMaxWidth: false }, config.er),
        flowchart: Object.assign({ useMaxWidth: false }, config.flowchart)
      }))
    }
    window.mermaid.__docsHubPatched = true
    return true
  }

  var patchTries = 0
  var patchTimer = setInterval(function () {
    patchTries += 1
    if (patchMermaidConfig() || patchTries > 200) clearInterval(patchTimer)
  }, 25)

  if (window.document$) {
    document$.subscribe(bind)
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", bind)
  } else {
    bind()
  }
})()
```

# ナビの見え方(完成条件)

- ヘッダー直下にタブ。トップレベル(Home と guide / architecture / ops など)がタブになる
- 左ナビは各系統(使い方・アーキテクチャ・運用など)が親。横に > があり、子ページが格納される
- 親はデフォルトで閉じている。現在ページへの経路だけ開く
- 右上に GitHub リポジトリ名と、ライト/ダーク切替がある
- 本文右上に編集ボタンがある(repo_url + edit_uri から組み立て。overrides は作らない)
- 見出しホバーで ¶ パーマリンク
- コードブロックホバーでコピー
- コードブロックホバーで行選択ボタンが出る。押したあと行をクリック/ドラッグして範囲を選べる
- 検索は入力中に候補が出る
- 検索を開くとタグフィルター(Filters)が最初から開いている
- ページ遷移はフルリロードしない
- スクロールで URL に #見出し が付く
- 右目次は現在の見出しについてくる
- コンテンツの横幅は既定より広い(`.md-grid` の max-width が 90rem)
- 言語指定 mermaid のフェンスは図になる。コード添付ではない
- 図の右上に虫眼鏡だけがある。文言のボタンは出さない。title は「拡大」
- 虫眼鏡を押すと拡大する。ホイールで拡大縮小、ドラッグで移動できる
- Esc または右上の × で閉じる。× もアイコンだけである

# 禁止

- navigation.sections
- custom_dir / overrides(単一リポジトリでは不要)
- features の追加・削除・並べ替え以外の改変
- zensical.toml への nav 追加