Skip to content

[codex] Refresh GitHub beginners QA dependencies #132

[codex] Refresh GitHub beginners QA dependencies

[codex] Refresh GitHub beginners QA dependencies #132

Workflow file for this run

name: Book QA (Unicode + Links + Textlint + Layout Risk)
on:
pull_request:
push:
branches: [ main ]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
qa:
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
steps:
- name: Checkout book
uses: actions/checkout@v6
- name: Bidi/hidden Unicode check (tracked files)
run: python3 scripts/check_bidi_unicode.py
- name: Checkout book-formatter (pinned)
uses: actions/checkout@v6
with:
repository: itdojp/book-formatter
ref: da2a49e7d2dcd9e1fa885e910c458130fe8d73a4
path: book-formatter
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: npm
cache-dependency-path: book-formatter/package-lock.json
- name: Check metadata consistency
run: node scripts/check-metadata-consistency.js
- name: Install dependencies (book-formatter)
working-directory: book-formatter
run: npm ci
- name: Determine scan directory
id: scan
shell: bash
run: |
if [ -d docs ]; then
echo "dir=docs" >> "$GITHUB_OUTPUT"
else
echo "dir=." >> "$GITHUB_OUTPUT"
fi
- name: Unicode check (fail on warnings)
run: node book-formatter/scripts/check-unicode.js "${{ steps.scan.outputs.dir }}" --allowlist .book-formatter/unicode-allowlist.json --fail-on warn
- name: Textlint (PRH dictionary; fail on errors)
run: node book-formatter/scripts/check-textlint.js "${{ steps.scan.outputs.dir }}" --fail-on error
- name: Link check (internal + anchors)
run: node book-formatter/scripts/check-links.js "${{ steps.scan.outputs.dir }}"
- name: Layout risk scan (long lines / tables / images)
run: node book-formatter/scripts/check-layout-risk.js "${{ steps.scan.outputs.dir }}" --fail-on error --output "${{ runner.temp }}/layout-risk-report.json"
- name: Markdown structure check (front matter / headings / fences)
run: node book-formatter/scripts/check-markdown-structure.js "${{ steps.scan.outputs.dir }}" --fail-on error --output "${{ runner.temp }}/markdown-structure-report.json"
- name: Upload markdown structure report
if: always()
uses: actions/upload-artifact@v7
with:
name: markdown-structure-report
path: ${{ runner.temp }}/markdown-structure-report.json
if-no-files-found: ignore
- name: Upload layout risk report
if: always()
uses: actions/upload-artifact@v7
with:
name: layout-risk-report
path: ${{ runner.temp }}/layout-risk-report.json
if-no-files-found: ignore
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Build (Jekyll; GitHub Pages compatible)
uses: actions/jekyll-build-pages@v1
with:
source: ./${{ steps.scan.outputs.dir }}
destination: ./_site
- name: Smoke check built site (top + navigation + assets)
shell: bash
run: |
python3 -m pip install --user pyyaml
python3 - << 'PY'
import sys
from pathlib import Path
import yaml
scan_dir = Path("${{ steps.scan.outputs.dir }}").resolve()
site_dir = Path("_site").resolve()
if not site_dir.exists():
print(f"::error::Built site directory not found: {site_dir}")
sys.exit(1)
def normalize_path(p):
if not isinstance(p, str):
return None
p = p.strip()
if not p:
return None
if p.startswith(("http://", "https://", "mailto:")):
return None
if not p.startswith("/"):
p = "/" + p
lower = p.lower()
if lower.endswith((".md", ".html", ".htm", ".pdf", ".txt")):
return p
return p if p.endswith("/") else p + "/"
def read_nav_paths():
nav = scan_dir / "_data" / "navigation.yml"
if not nav.exists():
return []
try:
raw = nav.read_text(encoding="utf-8")
data = yaml.safe_load(raw) or {}
except yaml.YAMLError as e:
nav_display = nav
try:
nav_display = nav.relative_to(Path.cwd())
except ValueError:
pass
print(f"::error file={nav_display}::Failed to parse navigation YAML: {e}")
sys.exit(1)
paths = []
# Keep this segment list broad to support different book structures.
for key in ["introduction", "chapters", "additional", "resources", "appendices", "afterword"]:
for item in (data.get(key) or []):
if isinstance(item, dict):
nested = item.get("items")
if not isinstance(nested, list):
nested = item.get("children")
if isinstance(nested, list):
for sub in (nested or []):
if not isinstance(sub, dict):
continue
p = normalize_path(sub.get("path"))
if p:
paths.append(p)
continue
if not isinstance(item, dict):
continue
p = normalize_path(item.get("path"))
if p:
paths.append(p)
return paths
def discover_paths():
paths = []
for seg in ["introduction", "chapters", "additional", "resources", "appendices", "afterword"]:
d = scan_dir / seg
if d.is_dir():
for child in sorted(d.iterdir()):
if child.is_dir():
paths.append(f"/{seg}/{child.name}/")
return paths
paths = read_nav_paths() or discover_paths() or ["/"]
if "/" not in paths:
paths.insert(0, "/")
# De-dup while keeping order
seen = set()
uniq = []
for p in paths:
if p in seen:
continue
seen.add(p)
uniq.append(p)
paths = uniq
def exists_any(candidates):
for c in candidates:
if c.exists() and c.is_file() and c.stat().st_size > 0:
return True
return False
def candidates_for(path_str):
if path_str == "/":
return [site_dir / "index.html"]
rel = path_str.lstrip("/")
lower = path_str.lower()
if lower.endswith((".html", ".htm", ".pdf", ".txt")):
return [site_dir / rel]
if lower.endswith(".md"):
# Jekyll usually converts Markdown to HTML, but Markdown without front matter may be copied as-is.
html_rel = rel[:-3] + ".html"
return [site_dir / rel, site_dir / html_rel]
# Directory-like (pretty permalink)
rel_dir = rel
if not rel_dir.endswith("/"):
rel_dir += "/"
# Both `/<dir>/index.html` and `/<dir>.html` are accepted.
return [site_dir / rel_dir / "index.html", site_dir / (rel_dir[:-1] + ".html")]
missing = []
for p in paths:
if not exists_any(candidates_for(p)):
missing.append(p)
# Ensure core assets exist in the built output.
required_assets = [
"assets/css/main.css",
"assets/css/syntax-highlighting.css",
"assets/js/theme.js",
"assets/js/search.js",
"assets/js/code-copy-lightweight.js",
]
missing_assets = [
a
for a in required_assets
if not (
(site_dir / a).exists()
and (site_dir / a).is_file()
and (site_dir / a).stat().st_size > 0
)
]
if missing or missing_assets:
if missing:
print("::error::Missing pages in built site:")
for p in missing:
print(f" - {p}")
if missing_assets:
print("::error::Missing assets in built site:")
for a in missing_assets:
print(f" - {a}")
sys.exit(1)
print(f"OK: built site smoke check passed ({len(paths)} paths, {len(required_assets)} assets)")
PY