🗂️

Zenn の記事をフォルダ管理したい

に公開

この記事は Zenn の記事を GitHub で管理している方向けの記事です。

記事をフォルダ管理したい

Zenn には GitHub 連携機能が備わっており、自前のエディターで記事を書いてリポジトリに push すると記事を更新できるので、とても便利です。

https://zenn.dev/zenn/articles/connect-to-github

しかし、articles/ フォルダ直下にあるファイルしか記事判定されないので、増えてくると管理が大変になってきます。

仕様上のフォルダ構造
.
└─ articles/
    ├── article1.md
    ├── article2.md
    ├── article3.md
   ...

これを以下のように管理したいです。

理想
.
└─ articles/
    ├── category1/
    │   ├── article1.md
    │   └── article2.md
    ├── category2/
    │   ├── article3.md
    │   ├── article4.md
    │   └── article5.md
    ├── article6.md
   ...

もちろんこの通りに実現するのは仕様上無理なので、擬似的に実現する方法を紹介します。

実現方法

概ね以下の流れで実現します。

  1. 記事の本体を別のフォルダ (例えば contents/ ) で作成し、contents/ 以下は自由にフォルダ分けします。
  2. 記事ごとに設定ファイル (config.yaml) と記事の中身 (名前はなんでも良いが、contents.md とする) を用意します。
  3. contents.md 監視して、変更があれば自動的に articles/ のファイルを更新する。

変更を検知してリアルタイムに articles/ 以下を更新することで、zenn-cli のプレビュー機能の恩恵を受けられます。

https://zenn.dev/zenn/articles/zenn-cli-guide#プレビューする

フォルダ構造例
フォルダ構造
.
├── articles/
│   ├── article1.md
│   ├── article2.md
│  ...
└── contents/
    ├── category1/
    │   ├── article1/
    │   │   ├── config.yaml
    │   │   └── content.md
    │   ├── article2/
    │   │   ├── config.yaml
    │   │   └── content.md
    │   ...
    ├── category2/
    ...

設定ファイルの仕様

config.yaml の例
この記事のconfig.yaml
front_matter:
  title: "Zenn の記事をフォルダ管理したい"
  emoji: "🗂️"
  type: "idea" # tech: 技術記事 / idea: アイデア
  topics: ["Zenn", "記事管理"]
  published: false
slug: how_to_manage_zenn_articles
base_path: "contents/zenn/manage_articles"
sections:
  - "article.md"

以下の内容を記載します。

  • front_matter

    • 記事のタイトル等の情報を記載します。記事の頭に書くやつです。
  • slug

    • 記事の slug。artiles/[slug].md が生成されます。
  • base_path

    • 監視するファイルのベースのフォルダ
  • sections

    • コンテンツファイルの base_path からの相対パス
    • 元々記事そのものを分割しようと思って作ったので、複数のファイルを指定できます。単純に上から順に連結します。

front_matter は markdown に含めても良かったですが、published は config で管理すべきだと思ったのでこうしました。

コード

コードを貼っておくので自由に使ってください。
zenn-cli が node なので node で書いた方がいい気がしますが、楽なので python で書きました。

スクリプト
実行方法
python script.py watch [config.yamlのパス]
script.py
from pathlib import Path
from typing import Literal

import click
import watchfiles
import yaml
from pydantic import BaseModel, Field


class FrontMatter(BaseModel):
    """記事の頭のメタ情報"""

    title: str = Field(..., description="記事のタイトル")
    emoji: str = Field(..., description="絵文字")
    type: Literal["tech", "idea"] = Field(
        ..., description="tech: 技術記事 / idea: アイデア"
    )
    topics: list[str] = Field(..., description="検索用のタグ")
    published: bool = Field(..., description="公開するかどうか")

    def get_front_matter_str(self) -> str:
        topics_str = ", ".join([f'"{topic}"' for topic in self.topics])
        return f"""---
title: "{self.title}"
emoji: "{self.emoji}"
type: "{self.type}" # tech: 技術記事 / idea: アイデア
topics: [{topics_str}]
published: {"true" if self.published else "false"}
---
"""


class ArticleConfig(BaseModel):
    slug: str = Field(
        ...,
        description="記事のスラッグ",
        min_length=12,
        max_length=50,
        pattern=r"^[a-z0-9_-]+$",
    )
    base_path: str = Field(..., description="Base path for the article contents")
    sections: list[str] = Field(
        ..., description="セクション (mdファイル) の base_path からの相対パス"
    )
    front_matter: FrontMatter = Field(...)

    def get_section_paths(self) -> list[Path]:
        return [Path(self.base_path) / section for section in self.sections]


def update_article(config: ArticleConfig):
    md_str_list = [config.front_matter.get_front_matter_str()]

    for section_path in config.get_section_paths():
        with open(section_path, "r") as f:
            section_str = f.read()
        md_str_list.append(section_str)

    md_str = "\n".join(md_str_list)
    with open(f"articles/{config.slug}.md", "w") as f:
        f.write(md_str)


@click.group()
def cli():
    pass


@cli.command(help="config.yaml から記事を生成")
@click.argument("conf_yaml_path", type=str)
def make(conf_yaml_path: str):
    """Generate markdown file from config.yaml"""
    with open(conf_yaml_path, "r") as f:
        conf_dict = yaml.safe_load(f)

    conf = ArticleConfig(**conf_dict)
    update_article(conf)


@cli.command(help="config.yaml から記事を生成 (監視モード)")
@click.argument("conf_yaml_path", type=str)
def watch(conf_yaml_path: str):
    with open(conf_yaml_path, "r") as f:
        conf_dict = yaml.safe_load(f)

    conf = ArticleConfig(**conf_dict)
    update_article(conf)
    watch_paths = [conf_yaml_path] + conf.get_section_paths()

    print("Watching for changes...")
    print("\n".join([conf_yaml_path] + conf.sections))
    print("\n")

    for changes in watchfiles.watch(*watch_paths):
        update_article(conf)
        print(f"Updated: {changes}")


if __name__ == "__main__":
    cli()

※ config.yaml も監視対象になっていますが、更新しても更新されません。再起動してください。

GitHubで編集を提案

Discussion