Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/scripts/sync_to_bokushi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Generate Bokushi blog post content from README metadata."""

from __future__ import annotations

import ast
import datetime as dt
import json
import os
import re
import sys
from typing import Any, Dict, Tuple


FRONTMATTER_PATTERN = re.compile(r"<!--(.*?)-->", re.DOTALL)
DATE_PLACEHOLDER = re.compile(r"\$\((?:date \+%Y-%m-%d)\)")


def parse_frontmatter(readme_text: str) -> Tuple[Dict[str, Any], str]:
match = FRONTMATTER_PATTERN.search(readme_text)
if not match:
raise ValueError("README.md missing expected HTML comment frontmatter block.")

frontmatter_raw = match.group(1)
metadata: Dict[str, Any] = {}
for line in frontmatter_raw.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()

if not value:
metadata[key] = ""
continue

if value.startswith("["):
# Parse list-based values like tags.
metadata[key] = ast.literal_eval(value)
else:
metadata[key] = value

body = readme_text[match.end() :].lstrip()
return metadata, body


def format_date(date_str: str) -> Tuple[str, str]:
if not date_str:
today = dt.date.today()
return today.isoformat(), today.strftime("%b %d, %Y")
try:
parsed = dt.datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
# Leave unparsed value as-is for ISO and display.
return date_str, date_str
return parsed.isoformat(), parsed.strftime("%b %d, %Y")


def resolve_title(raw_title: str, metadata: Dict[str, Any]) -> str:
if raw_title and DATE_PLACEHOLDER.search(raw_title):
replacement = (
metadata.get("modified")
or metadata.get("date")
or dt.date.today().isoformat()
)
raw_title = DATE_PLACEHOLDER.sub(replacement, raw_title)
return raw_title


def build_frontmatter(metadata: Dict[str, Any]) -> Dict[str, Any]:
title = resolve_title(metadata.get("title", ""), metadata)
tags = metadata.get("tags", [])
if not isinstance(tags, list):
tags = [str(tags)]

cover = metadata.get("cover", "")

pub_iso, pub_display = format_date(metadata.get("date", ""))
mod_iso, mod_display = format_date(metadata.get("modified", "") or pub_iso)

return {
"title": title,
"tags": tags,
"socialImage": cover,
"pubDate": pub_display,
"updatedDate": mod_display,
}


def render_frontmatter(frontmatter: Dict[str, Any]) -> str:
lines = ["---"]
lines.append(f"title: {frontmatter['title']}")
lines.append(f"tags: {json.dumps(frontmatter['tags'], ensure_ascii=False)}")
if frontmatter["socialImage"]:
lines.append(f"socialImage: {frontmatter['socialImage']}")
lines.append(f'pubDate: "{frontmatter["pubDate"]}"')
lines.append(f'updatedDate: "{frontmatter["updatedDate"]}"')
lines.append("---")
lines.append("")
return "\n".join(lines)


def write_output(path: str, content: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)


def main() -> None:
if len(sys.argv) != 3:
raise SystemExit("Usage: sync_to_bokushi.py <README.md> <output.md>")

readme_path, output_path = sys.argv[1:]
with open(readme_path, "r", encoding="utf-8") as f:
readme_text = f.read()

metadata, body = parse_frontmatter(readme_text)
frontmatter = build_frontmatter(metadata)

rendered_frontmatter = render_frontmatter(frontmatter)
content = f"{rendered_frontmatter}{body}"

write_output(output_path, content)


if __name__ == "__main__":
main()
52 changes: 52 additions & 0 deletions .github/workflows/sync-to-bokushi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Sync to Bokushi

on:
push:
branches: [main]
paths:
- README.md
- publish.sh
- .github/scripts/sync_to_bokushi.py
workflow_dispatch:

jobs:
sync:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'niracler' }}

steps:
- name: Checkout source repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Generate Bokushi content
run: |
mkdir -p generated
python .github/scripts/sync_to_bokushi.py README.md generated/plrom.md

- name: Checkout Bokushi repository
uses: actions/checkout@v4
with:
repository: niracler/bokushi
token: ${{ secrets.BOKUSHI_SYNC_TOKEN }}
path: bokushi
fetch-depth: 0

- name: Update blog post
run: cp generated/plrom.md bokushi/src/content/blog/plrom.md

- name: Create pull request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.BOKUSHI_SYNC_TOKEN }}
path: bokushi
commit-message: "chore: sync plrom content from plrom repo"
branch: chore/sync-plrom-${{ github.run_id }}
title: "Sync plrom content from plrom repo"
body: |
Automated sync triggered by https://github.com/${{ github.repository }}/commit/${{ github.sha }}.
base: main
4 changes: 4 additions & 0 deletions .markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"MD013": false,
"MD033": false
}
1 change: 1 addition & 0 deletions AGENTS.md
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ Configuration is in [.linkspector.yml](.linkspector.yml) which ignores certain d

- Validates all links in README.md on pushes and PRs

### Bokushi Sync Workflow

[.github/workflows/sync-to-bokushi.yml](.github/workflows/sync-to-bokushi.yml):

- **Trigger**: Pushes to `main` (README/publish tooling changes) or manual `workflow_dispatch`
- **Process**: Renders README metadata with [.github/scripts/sync_to_bokushi.py](.github/scripts/sync_to_bokushi.py), updates `niracler/bokushi`'s `src/content/blog/plrom.md`, and opens a PR via `peter-evans/create-pull-request`
- **Secret Required**: `BOKUSHI_SYNC_TOKEN` (Classic PAT with `repo` scope) so the workflow can push branches to `niracler/bokushi`

## Content Structure

The [README.md](README.md) is organized into three main sections:
Expand Down
Loading