iTranslated by AI
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)
Discussion