Skip to content

Merge remote-tracking branch 'origin/main' #91

Merge remote-tracking branch 'origin/main'

Merge remote-tracking branch 'origin/main' #91

name: Normalize GitBook assets for Git it Write
on:
push:
branches: [ main ]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: normalize-assets-${{ github.ref }}
cancel-in-progress: true
jobs:
normalize:
if: ${{ github.actor != 'github-actions[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Configure author
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update to latest main (rebase)
run: |
git fetch origin main
git checkout main
git rebase origin/main
- name: Ensure _images exists (flat)
run: mkdir -p _images
# 1) Copy images from .gitbook/assets → _images (flat)
- name: Copy from .gitbook/assets → _images (flat)
shell: bash
run: |
set -euo pipefail
if [ -d ".gitbook/assets" ]; then
while IFS= read -r -d '' src; do
base="$(basename "$src")"
cp -f "$src" "_images/$base"
done < <(find .gitbook/assets -type f -print0)
fi
# 2) Rename files in _images to remove spaces (spaces -> '-')
- name: Slugify filenames in _images (remove spaces)
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
for f in _images/*; do
b="$(basename "$f")"
nb="$(printf '%s' "$b" | sed -E 's/%20/ /g; s/[[:space:]]+/-/g; s/-+/-/g')"
if [ "$b" != "$nb" ]; then
tgt="_images/$nb"
if [ -e "$tgt" ]; then
# If target exists and content is IDENTICAL → drop the duplicate
if cmp -s "$f" "$tgt"; then
git rm -f "$f" 2>/dev/null || rm -f "$f"
echo "Removed duplicate identical file: $b (kept $(basename "$tgt"))"
continue
fi
# Else different content → create a unique name (rare)
base="${nb%.*}"; ext="${nb##*.}"; i=1
while [ -e "_images/${base}-${i}.${ext}" ]; do i=$((i+1)); done
tgt="_images/${base}-${i}.${ext}"
fi
git mv -f "$f" "$tgt" 2>/dev/null || mv -f "$f" "$tgt"
echo "Renamed: $b -> $(basename "$tgt")"
fi
done
# 3) Convert <figure><img ...><figcaption>...</figcaption></figure> → ![alt](/_images/file "alt")
- name: Convert <figure><img> blocks to Markdown images (/_images, no spaces)
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import re, glob, html, os
def slug_filename(name:str)->str:
name = name.replace('%20',' ')
name = re.sub(r'\s+', '-', name) # spaces -> dash
name = re.sub(r'-{2,}', '-', name) # collapse --
return name
files = [p for g in ["**/*.md","**/*.MD","**/*.mdx","**/*.MDX","**/*.markdown","**/*.MARKDOWN"]
for p in glob.glob(g, recursive=True)]
FIG = re.compile(r'<figure\b[^>]*>(.*?)</figure>', re.I|re.S)
IMG = re.compile(r'<img\b[^>]*>', re.I|re.S)
CAP = re.compile(r'<figcaption\b[^>]*>(.*?)</figcaption>', re.I|re.S)
def attr(n,s):
m = re.search(rf'\b{n}\s*=\s*["\']([^"\']*)["\']', s, re.I|re.S)
return m.group(1).strip() if m else ''
def convert(block:str)->str:
m = IMG.search(block)
if not m: return block
img = m.group(0)
raw_src = attr('src', img)
alt = attr('alt', img)
# title = figcaption (preferred), else ALT
mcap = CAP.search(block)
cap = html.unescape(re.sub(r'<[^>]+>','',mcap.group(1))).strip() if mcap else ""
title = cap or alt
fn = slug_filename(os.path.basename(raw_src))
url = f"/_images/{fn}"
alt_md = alt.replace(']', r'\]')
title_md = title.replace('"', r'\"') # keep curly quotes, escape only "
return f'![{alt_md}]({url} "{title_md}")'
for path in files:
s = open(path, encoding="utf-8").read()
n = FIG.sub(lambda m: convert(m.group(0)), s)
if n != s:
open(path, "w", encoding="utf-8").write(n)
print(f"Converted figures in: {path}")
PY
# 4) Normalize any remaining image links to /_images/<no-spaces>, remove prefixes/subfolders, convert bare <img> too
- name: Normalize ALL image links to ![alt](/_images/<slug> "alt") exactly
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import re, glob, os
from urllib.parse import unquote
from html import unescape
IMG_EXT = r"(?:png|jpe?g|gif|webp|svg|gifv)"
def clean_filename_from_url(url: str) -> str:
if not url:
return "unknown.png" # safety
# Trim noise that can leak from <...> wrappers and empty titles
url = url.strip().rstrip('>')
url = re.sub(r'\s*""', '', url)
# pick the last filename-looking token with an image extension
m = re.findall(rf'([^/?#]+?\.{IMG_EXT})', url, flags=re.I)
bn = m[-1] if m else os.path.basename(url)
bn = unquote(bn)
# split into base + ext
if "." in bn:
base, ext = bn.rsplit(".", 1)
else:
base, ext = bn, ""
# --- NEW: if a quoted fragment got appended before the extension,
# drop everything from the first quote to the end.
# e.g. image-(766 "Clicking …").png -> image-(766)
base = re.sub(r'["“”].*$', '', base).strip()
# normalize: spaces -> dash; keep (), -, _
base = re.sub(r"\s+", "-", base)
base = re.sub(r"[^A-Za-z0-9_\-()]", "-", base)
base = re.sub(r"-{2,}", "-", base).strip("-")
return f"{base}.{ext.lower()}" if ext else base
def to_root_image_url(url: str) -> str:
return "/_images/" + clean_filename_from_url(url)
def norm_text(s: str) -> str:
t = unescape(s or "")
# normalize curly quotes to straight quotes
t = t.replace("“", '"').replace("”", '"').replace("’", "'")
return t
def build_md(alt: str, url: str) -> str:
"""Return strict Markdown. If alt is empty -> omit title entirely."""
alt = norm_text(alt)
alt_md = alt.replace("]", r"\]")
if alt.strip():
title = alt.replace('"', r'\"') # title = alt
return f'![{alt_md}]({to_root_image_url(url)} "{title}")'
else:
return f'![{alt_md}]({to_root_image_url(url)})'
md_files = [p for g in ("**/*.md","**/*.MD","**/*.mdx","**/*.MDX","**/*.markdown","**/*.MARKDOWN")
for p in glob.glob(g, recursive=True)]
# --- Hint → blockquote converter ---
HINT_BLOCK = re.compile(r'{%\s*hint\b[^%]*%}(.*?){%\s*endhint\s*%}', re.I | re.S)
MD_LINK = re.compile(r'\[([^\]]+)\]\((https?://[^)]+)\)')
BARE_URL = re.compile(r'^(https?://\S+)$')
def convert_hint_blocks(s: str) -> str:
def _repl(m):
inner = m.group(1).strip()
paras = []
for line in inner.splitlines():
line = line.strip()
if not line:
continue
line = MD_LINK.sub(r'<a href="\2">\1</a>', line)
bu = BARE_URL.match(line)
if bu:
u = bu.group(1)
line = f'<a href="{u}">{u}</a>'
paras.append(f"<p>{line}</p>")
content = "\n".join(paras) if paras else "<p></p>"
return f'<blockquote class="wp-block-quote">\n{content}\n</blockquote>'
return HINT_BLOCK.sub(_repl, s)
# HTML <img> (alt optional)
html_img = re.compile(
r'<img\b[^>]*\bsrc=["\']([^"\']+)["\'][^>]*?(?:\balt=["\']([^"\']*)["\'])?[^>]*>',
re.I
)
# Inline Markdown images (URL may be quoted, spaces allowed, optional title)
md_img_inline = re.compile(
r'!\[([^\]]*)\]'
r'\('
r'\s*<?'
r'("?)([^)\r\n]+?)\1' # group 3: URL
r'>?'
r'(?:\s+"[^"]*")?'
r'\s*\)',
re.I
)
# Sanitize ANY Markdown image (even ones already pointing to /_images)
md_img_any = re.compile(
r'!\[([^\]]*)\]' # alt
r'\('
r'\s*<?'
r'(?:' # EITHER a quoted URL...
r'"([^"]+)"' # group 2 = quoted URL
r'|' # OR an unquoted URL without spaces
r'([^\s)<>]+)' # group 3 = unquoted URL
r')'
r'>?' # optional '>'
r'(?:\s+"[^"]*")?' # optional "title"
r'\s*\)',
re.I
)
# Reference usage and definition (loose)
md_img_ref_use = re.compile(r'!\[([^\]]*)\]\s*\[([^\]]+)\]', re.I)
ref_def_loose = re.compile(
r'(\[([^\]]+)\]\s*:\s*)' r'<?("?)([^>\r\n]+?)\3>?' r'(\s+"[^"]*")?\s*$',
re.I
)
any_changed = False
for path in md_files:
with open(path, encoding="utf-8") as f:
s = f.read()
o = s
# 1) HTML <img> → strict MD
s = html_img.sub(lambda m: build_md(m.group(2) or "", m.group(1)), s)
# 2) Inline MD → strict MD (title = alt when alt non-empty)
s = md_img_inline.sub(lambda m: build_md(m.group(1), m.group(3)), s)
# 2.5) GitBook hint blocks → Gutenberg blockquote
s = convert_hint_blocks(s)
# 2.6) Sanitize ANY Markdown image URL (fix spaces, stray '>', etc.)
#s = md_img_any.sub(lambda m: build_md(m.group(1), m.group(2)), s)
def _sanitize_md_img(m):
alt = m.group(1)
url = (m.group(2) or m.group(3) or "").strip()
if not url:
return m.group(0) # leave it unchanged if URL missing
return build_md(alt, url)
s = md_img_any.sub(_sanitize_md_img, s)
# 3) Gather reference defs
defs = {}
lines = s.splitlines()
for ln in lines:
m = ref_def_loose.search(ln)
if m:
rid = m.group(2).strip()
url = m.group(4).strip()
defs[rid] = url
# 4) Reference usages → strict MD
def repl_ref_use(m):
alt, rid = m.group(1), m.group(2).strip()
url = defs.get(rid)
return build_md(alt, url) if url else m.group(0)
s = md_img_ref_use.sub(repl_ref_use, s)
# 5) Rewrite reference defs to /_images/<slug> (preserve title if present)
def rewrite_def_line(ln: str) -> str:
m = ref_def_loose.search(ln)
if not m:
return ln
head, url, title = m.group(1), m.group(4).strip(), m.group(5) or ""
new_url = to_root_image_url(url)
return f"{head}{new_url}{title}"
s = "\n".join(rewrite_def_line(ln) for ln in s.splitlines())
# 6) Catch-all: fix bare .gitbook/assets occurrences (outside code fences)
out_lines, fenced = [], False
fence_re = re.compile(r'^\s*```')
asset_re = re.compile(r'(\.gitbook/assets/[^"\'<>]+)', re.I) # allow spaces
for ln in s.splitlines():
if fence_re.match(ln):
fenced = not fenced
out_lines.append(ln); continue
if not fenced and '.gitbook/assets/' in ln:
ln = asset_re.sub(lambda m: to_root_image_url(m.group(1)), ln)
out_lines.append(ln)
s = "\n".join(out_lines)
# 7) Remove lingering ../ or ./ before _images
s = re.sub(r'\]\(\s*<?(?:\.\./|\.?/)+_images/', '](/_images/', s)
if s != o:
with open(path, "w", encoding="utf-8") as f:
f.write(s)
print(f"Fixed: {path}")
any_changed = True
if not any_changed:
print("No image links needed normalization.")
PY
# 5) Validate: all image links are /_images/<no-spaces> and no subfolders
- name: Validate image links style (/_images/*, no spaces)
shell: bash
run: |
set -euo pipefail
python3 - <<'PY'
import re, glob, sys
exts = r"(png|jpe?g|gif|webp|svg|gifv)"
is_img = re.compile(rf"\.({exts})(?:$|\?)", re.I)
# Inline MD
md_img_inline = re.compile(
r'!\[([^\]]*)\]' # alt text
r'\('
r'\s*<?'
r'("?)([^)\r\n]+?)\1' # group 3 = URL only
r'>?' # optional >, not captured
r'(?:\s+"[^"]*")?'
r'\s*\)',
re.I
)
# HTML
html_src = re.compile(r'\bsrc=["\']([^"\']+)["\']', re.I)
# Loose ref def (line-by-line)
ref_def_loose = re.compile(
r'(\[([^\]]+)\]\s*:\s*)' r'<?("?)([^>\r\n]+?)\3>?' r'(\s+"[^"]*")?\s*$',
re.I
)
def bad(u: str) -> bool:
u = u.strip()
if u.startswith("http://") or u.startswith("https://"):
return False # ignore external images
if not is_img.search(u):
return False
if not u.startswith("/_images/"):
return True
rest = u[len("/_images/"):]
if "/" in rest:
return True
if " " in u:
return True
return False
bads = []
files = [p for g in ("**/*.md","**/*.MD","**/*.mdx","**/*.MDX","**/*.markdown","**/*.MARKDOWN")
for p in glob.glob(g, recursive=True)]
for p in files:
with open(p, encoding="utf-8") as f:
s = f.read()
# Inline MD
for m in md_img_inline.finditer(s):
u = m.group(2).strip()
if bad(u): bads.append((p, u))
# HTML
for u in html_src.findall(s):
if bad(u): bads.append((p, u))
# Ref defs
for ln in s.splitlines():
m = ref_def_loose.search(ln)
if m:
u = m.group(4).strip()
if bad(u): bads.append((p, u))
if bads:
print("Non-compliant image links:")
for p, u in bads:
print(f"- {p}: {u}")
sys.exit(1)
print("All local image links are /_images/<file> with no spaces and no subfolders. External URLs are ignored.")
PY
- name: Commit changes (if any)
run: |
if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "Normalize: convert figures and links to ![alt](/_images/file \"alt\"), no spaces [ci skip]"
else
echo "No changes to commit."
fi
- name: Rebase on latest and push (retry with --force-with-lease)
shell: bash
run: |
set -euo pipefail
git fetch origin main --prune
if [ "$(git rev-list --count HEAD ^origin/main)" -eq 0 ]; then
echo "No new commits to push."
exit 0
fi
for a in 1 2 3; do
echo "Push attempt $a..."
if git push --force-with-lease; then exit 0; fi
git rebase origin/main
sleep 2
done
echo "Giving up after 3 attempts."; exit 1