Merge remote-tracking branch 'origin/main' #91
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> →  | |
| - 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'' | |
| 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  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'} "{title}")' | |
| else: | |
| return f'})' | |
| 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 , 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 |