iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🤖

Using a Flexible Folder Structure with Zenn's GitHub Integration

に公開

What I Want to Achieve

Zenn's GitHub integration is convenient, but it forces a folder structure like this:

articles/
  ├── article1.md
  └── article2.md
images/
  ├── image1.png
  └── image2.png

While this is fine, for my own management convenience and when using image pasting plugins in VSCode, I prefer a folder structure like the following:

articles/
  ├── 2025/
  │   ├── 20250101/
  │   │   ├── article1.md
  │   │   └── image1.png
  │   └── 20250202/
  │       ├── article2.md
  │       └── image2.png
  └── 2024/
      └── 20241212/
          ├── article3.md
          └── image3.png

Essentially, it's a structure organized by year/month/day, containing both the article and its images within the same folder.

Also, there's a slightly frustrating specification where image file references must be absolute paths starting with /images/....
When using VSCode's paste feature, the filename becomes something like image.png, but if you rename it with the F2 key, it changes to a relative path like ../images/..., causing the image to stop displaying on Zenn.
I've been fixing this manually every time, but it's a hassle!

How I Did It

I decided to create a publish/zenn branch specifically for Zenn and reflect a version converted to Zenn's required folder structure there.
As for how to convert it, I automated the process by writing a Python script. The code is a bit rough and dirty, so please use it just for reference.
I tried to let AI do it, but it didn't work well at all, so I wrote it manually.

import os
from dataclasses import dataclass
from datetime import datetime


@dataclass
class ArticleImageInfo:
    image_path: str
    after_path: str
    image_name: str
    date_str: str
    is_large: bool


@dataclass
class ArticleInfo:
    markdown_path: str
    current_path: str
    after_path: str
    date_str: str
    contained_images: list[ArticleImageInfo]


# Checkout to the publish/zenn branch
# At that time, bring the contents of main as they are
os.system("git checkout -B publish/zenn main")

## Extract markdown files in the article folder
# (Search recursively)
markdown_files = [
    os.path.join(root, file)
    for root, _, files in os.walk("articles")
    for file in files
    if file.endswith(".md")
]

article_infos: list[ArticleInfo] = []

for file in markdown_files:
    dir = os.path.dirname(file)
    # The folder name is the date, so retrieve it
    date_str = os.path.basename(dir)
    # Replace '#' since it's used at the start of filenames
    new_file = os.path.join("articles", f"{date_str}-{os.path.basename(file)}").replace(
        "#", ""
    )
    image_files = [
        os.path.join(dir, img)
        for img in os.listdir(dir)
        if img.endswith((".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"))
    ]
    contained_images: list[ArticleImageInfo] = []
    for img in image_files:
        is_large = os.path.getsize(img) > 3 * 1024 * 1024
        after_path = (
            os.path.join("images", date_str, os.path.basename(img))
            if not is_large
            else img
        )
        contained_images.append(
            ArticleImageInfo(
                image_path=img,
                after_path=after_path,
                image_name=os.path.basename(img),
                date_str=date_str,
                is_large=is_large,
            )
        )
    article_infos.append(
        ArticleInfo(
            markdown_path=file,
            current_path=file,
            after_path=new_file,
            date_str=date_str,
            contained_images=contained_images,
        )
    )


# Actually move the files
for article in article_infos:
    # Markdown files
    before = article.current_path
    after = article.after_path
    os.makedirs(os.path.dirname(after), exist_ok=True)
    if os.path.exists(before):
        os.rename(before, after)
    # Image files
    for img_info in article.contained_images:
        if not img_info.is_large and img_info.image_path != img_info.after_path:
            os.makedirs(os.path.dirname(img_info.after_path), exist_ok=True)
            if os.path.exists(img_info.image_path):
                os.rename(img_info.image_path, img_info.after_path)

# Fix image paths inside markdown files
for article in article_infos:
    md_path = article.after_path
    if os.path.exists(md_path):
        with open(md_path, "r", encoding="utf-8") as f:
            content = f.read()
        for img_info in article.contained_images:
            img_name = img_info.image_name
            if img_info.is_large:
                # Throw an error if a large image is present in the text
                if f"({img_name})" in content:
                    raise Exception(
                        f"Image {img_info.image_path} is too large to upload. Please remove it from {md_path}."
                    )
            else:
                # Example: (image.png) -> (/images/yyyyMMdd/image.png)
                content = content.replace(
                    f"({img_name})", f"(/images/{img_info.date_str}/{img_name})"
                )
        with open(md_path, "w", encoding="utf-8") as f:
            f.write(content)

# Create an empty books folder and place a .keep file
os.makedirs("books", exist_ok=True)
with open("books/.keep", "w") as f:
    f.write("")

# Commit
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
os.system("git add .")
os.system(f'git commit -m "Publish to Zenn at {now}"')

By running python scripts/setup_zenn.py on the main branch, the publish/zenn branch is created, and the content converted to Zenn's folder structure is committed.
In this state, you can see a preview by running npx zenn, and reflect the changes to Zenn by running git push -f origin publish/zenn.

It's complete once you automate this with something like GitHub Actions.

name: Publish Zenn

on:
  push:
    branches:
      - main

permissions:
  contents: write
  id-token: write  

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.x'

      - name: Setup git
        run: |
          git config --global user.name 'github-actions[bot]'
          git config --global user.email 'github-actions[bot]@users.noreply.github.com'

      - name: Run setup_zenn.py
        run: python scripts/setup_zenn.py
        env:
          TZ: Asia/Tokyo

      - name: Force push to publish/zenn branch
        run: |
          git push -f origin publish/zenn

After that, just set the branch for GitHub integration to publish/zenn in the Zenn management dashboard.

I had been putting it off, but I'm satisfied now that I can manage my articles more easily.

TODO

  • Automatically translate into English and post to other platforms after pushing.
  • Detect and error out if an image file size exceeds 3MB (Implemented)
GitHubで編集を提案

Discussion