diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 122993b5..befca7f1 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -65,6 +65,7 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'pull_request' && github.sha || 'main' }} + fetch-depth: 0 - uses: conda-incubator/setup-miniconda@v3 with: diff --git a/.makim.yaml b/.makim.yaml index e047a4bb..55f230d7 100644 --- a/.makim.yaml +++ b/.makim.yaml @@ -20,25 +20,25 @@ groups: # 2) Convert .qmd blog posts to Markdown using Quarto # Run quarto render from inside each blog folder (Quarto rejects --output path). if ! command -v quarto >/dev/null 2>&1; then - echo "[EE] Quarto CLI is required but not found on PATH. Install it (e.g. conda install quarto) and retry." - exit 1 - fi - mapfile -d "" qmd_files < <(find "$SEARCH_DIR" -name "*.qmd" -print0) - for qmd_path in "${qmd_files[@]}"; do - dir=$(dirname "$qmd_path") - base=$(basename "$qmd_path" .qmd) - ( - cd "$dir" - quarto render "$base.qmd" --to gfm -M template=default - ) - done + echo "[WW] Quarto CLI not found on PATH. Skipping .qmd rendering and using existing Markdown files." + else + mapfile -d "" qmd_files < <(find "$SEARCH_DIR" -name "*.qmd" -print0) + for qmd_path in "${qmd_files[@]}"; do + dir=$(dirname "$qmd_path") + base=$(basename "$qmd_path" .qmd) + ( + cd "$dir" + quarto render "$base.qmd" --to gfm -M template=default + ) + done - # 3) Ensure generated index.md have YAML from .qmd (Quarto may drop it) - python scripts/inject-qmd-yaml-into-md.py + # 3) Ensure generated index.md have YAML from .qmd (Quarto may drop it) + python scripts/inject-qmd-yaml-into-md.py - # 4) Remove console colors from generated md files - find "$SEARCH_DIR" -name "index.md" -exec sh -c \ - 'cat "$(dirname "$0")/index.md" | python scripts/clean-output.py > "$(dirname "$0")/temp_index.md" && mv "$(dirname "$0")/temp_index.md" "$(dirname "$0")/index.md"' {} \; + # 4) Remove console colors from generated md files + find "$SEARCH_DIR" -name "index.md" -exec sh -c \ + 'cat "$(dirname "$0")/index.md" | python scripts/clean-output.py > "$(dirname "$0")/temp_index.md" && mv "$(dirname "$0")/temp_index.md" "$(dirname "$0")/index.md"' {} \; + fi build: help: build the static page diff --git a/mkdocs.yml b/mkdocs.yml index 19489f0a..b11a3c75 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -140,6 +140,7 @@ plugins: default_timezone: Europe/Paris default_time: "09:30" enabled: true + use_git: false feed_ttl: 1440 image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png length: 20 diff --git a/scripts/check-broken-links-md.py b/scripts/check-broken-links-md.py index 37ea8165..622d62af 100644 --- a/scripts/check-broken-links-md.py +++ b/scripts/check-broken-links-md.py @@ -2,8 +2,8 @@ from __future__ import annotations -import os import subprocess +import sys # List of exception URLs exception_urls = [ @@ -18,7 +18,16 @@ def process_log() -> None: try: subprocess.run( - ["python", "-m", "linkcheckmd", "-r", "-v", "-m", "get", "pages"], + [ + sys.executable, + "-m", + "linkcheckmd", + "-r", + "-v", + "-m", + "get", + "pages", + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -40,24 +49,29 @@ def process_log() -> None: if not line.startswith("("): continue - if line.endswith("429)"): - # Too Many Requests http error + if line.endswith("429)") or line.endswith("403)"): + # Rate limit / forbidden crawler error continue - # Extract the URL using regex - for exception_url in exception_urls: - if exception_url not in line: - flagged_errors.append(line) + + # Check if URL is in exception list + if any(exception_url in line for exception_url in exception_urls): + continue + + flagged_errors.append(line) # Print flagged errors if not flagged_errors: - print("[II] All links are ok.") - print("No errors flagged. All URLs are in the exception list.") + print("[II] All links are ok.", flush=True) + print( + "No errors flagged. All URLs are in the exception list.", + flush=True, + ) return - print("Errors flagged for the following URLs:") + print("Errors flagged for the following URLs:", flush=True) for line in flagged_errors: - print(line) - os._exit(1) + print(line, flush=True) + sys.exit(1) # Run the script diff --git a/tests/test_share_and_copy_link.py b/tests/test_share_and_copy_link.py new file mode 100644 index 00000000..d6e0b10a --- /dev/null +++ b/tests/test_share_and_copy_link.py @@ -0,0 +1,176 @@ +"""Test LinkedIn share and Copy Link markup.""" + +from __future__ import annotations + +import unittest + +from pathlib import Path + +from bs4 import BeautifulSoup + + +class TestShareAndCopyLinks(unittest.TestCase): + """Test LinkedIn share and Copy Link in templates and output.""" + + def setUp(self) -> None: + """Set up test paths.""" + self.root = Path(__file__).resolve().parents[1] + self.theme_dir = self.root / "theme" + + def test_blog_post_template_markup(self) -> None: + """Verify blog-post.html has correct LinkedIn and Copy Link tags.""" + template_file = self.theme_dir / "blog-post.html" + self.assertTrue(template_file.exists()) + content = template_file.read_text(encoding="utf-8") + + # Check LinkedIn share URL endpoint + expected_endpoint = ( + "https://www.linkedin.com/sharing/share-offsite/" + "?url={{ page.canonical_url or url }}" + ) + self.assertIn(expected_endpoint, content) + self.assertNotIn('href="#linkedinshare"', content) + + # Check target and rel + self.assertIn('target="_blank"', content) + self.assertIn('rel="nofollow noopener"', content) + + # Check Copy Link button + self.assertIn('class="link link_yank"', content) + self.assertIn('href="{{ page.canonical_url or url }}"', content) + + def test_blog_list_template_markup(self) -> None: + """Verify blog-list.html has correct LinkedIn and Copy Link tags.""" + template_file = self.theme_dir / "blog-list.html" + self.assertTrue(template_file.exists()) + content = template_file.read_text(encoding="utf-8") + + # Check LinkedIn share URL endpoint + expected_endpoint = ( + "https://www.linkedin.com/sharing/share-offsite/?url={{ url }}" + ) + self.assertIn(expected_endpoint, content) + self.assertNotIn('href="#linkedinshare"', content) + + # Check target and rel + self.assertIn('target="_blank"', content) + self.assertIn('rel="nofollow noopener"', content) + + # Check Copy Link button + self.assertIn('class="link link_yank"', content) + self.assertIn('href="{{ url }}"', content) + + def test_theme_js_copy_link_handler(self) -> None: + """Verify theme.js has click handler for .link_yank and feedback.""" + js_file = self.theme_dir / "js" / "theme.js" + self.assertTrue(js_file.exists()) + content = js_file.read_text(encoding="utf-8") + + self.assertIn(".link_yank", content) + self.assertIn("preventDefault", content) + self.assertIn("writeText", content) + self.assertIn("copy-tooltip", content) + self.assertIn("Copied!", content) + self.assertIn("execCommand", content) + + def test_blog_css_copy_link_styles(self) -> None: + """Verify blog.css styles .link_yank, .copied, and .copy-tooltip.""" + css_file = self.theme_dir / "css" / "blog.css" + self.assertTrue(css_file.exists()) + content = css_file.read_text(encoding="utf-8") + + self.assertIn(".link_yank", content) + self.assertIn(".link_yank.copied", content) + self.assertIn(".copy-tooltip", content) + + def test_rendered_blog_list_html(self) -> None: + """Verify rendered blog/index.html elements.""" + build_html = self.root / "build" / "blog" / "index.html" + if not build_html.exists(): + return + + soup = BeautifulSoup( + build_html.read_text(encoding="utf-8"), "html.parser" + ) + post_cards = soup.find_all("li", class_="post-card") + self.assertGreater(len(post_cards), 0) + + for card in post_cards: + share = card.find("div", class_="post_share") + self.assertIsNotNone(share) + + # LinkedIn button + linkedin = share.find("a", class_="linkedin") + self.assertIsNotNone(linkedin) + href = linkedin.get("href", "") + share_prefix = ( + "https://www.linkedin.com/sharing/share-offsite/?url=" + ) + self.assertTrue( + href.startswith(share_prefix), + f"Unexpected LinkedIn href: {href}", + ) + self.assertEqual(linkedin.get("target"), "_blank") + self.assertEqual(linkedin.get("rel"), ["nofollow", "noopener"]) + self.assertEqual(linkedin.get("title"), "Share on LinkedIn") + svg_use = linkedin.find("use") + self.assertIsNotNone(svg_use) + self.assertEqual(svg_use.get("xlink:href"), "#linkedin") + + # Copy link button + copy_btn = share.find("a", class_="link_yank") + self.assertIsNotNone(copy_btn) + self.assertTrue(bool(copy_btn.get("href"))) + self.assertIn(copy_btn.get("title"), ["Copy link", "Copy Link"]) + copy_svg_use = copy_btn.find("use") + self.assertIsNotNone(copy_svg_use) + self.assertEqual(copy_svg_use.get("xlink:href"), "#copy") + + def test_rendered_blog_post_html(self) -> None: + """Verify rendered blog post pages share panel elements.""" + build_dir = self.root / "build" / "blog" + if not build_dir.exists(): + return + + for post_html in build_dir.glob("*/index.html"): + soup = BeautifulSoup( + post_html.read_text(encoding="utf-8"), "html.parser" + ) + panel = soup.find("aside", class_="blog-share-panel") + if not panel: + continue + + # LinkedIn button + linkedin = panel.find("a", class_="linkedin") + self.assertIsNotNone( + linkedin, f"Missing LinkedIn button in {post_html}" + ) + href = linkedin.get("href", "") + share_prefix = ( + "https://www.linkedin.com/sharing/share-offsite/?url=" + ) + self.assertTrue( + href.startswith(share_prefix), + f"Unexpected LinkedIn href in {post_html}: {href}", + ) + self.assertEqual(linkedin.get("target"), "_blank") + self.assertEqual(linkedin.get("rel"), ["nofollow", "noopener"]) + self.assertEqual(linkedin.get("title"), "Share on LinkedIn") + svg_use = linkedin.find("use") + self.assertIsNotNone(svg_use) + self.assertEqual(svg_use.get("xlink:href"), "#linkedin") + + # Copy link button + copy_btn = panel.find("a", class_="link_yank") + self.assertIsNotNone( + copy_btn, f"Missing Copy link button in {post_html}" + ) + self.assertTrue(bool(copy_btn.get("href"))) + self.assertEqual(copy_btn.get("title"), "Copy link") + copy_svg_use = copy_btn.find("use") + self.assertIsNotNone(copy_svg_use) + self.assertEqual(copy_svg_use.get("xlink:href"), "#copy") + + +if __name__ == "__main__": + unittest.main() diff --git a/theme/blog-list.html b/theme/blog-list.html index 0b7b27ca..c58122f3 100644 --- a/theme/blog-list.html +++ b/theme/blog-list.html @@ -63,7 +63,8 @@