Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 17 additions & 17 deletions .makim.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 27 additions & 13 deletions scripts/check-broken-links-md.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

from __future__ import annotations

import os
import subprocess
import sys

# List of exception URLs
exception_urls = [
Expand All @@ -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,
Expand All @@ -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
Expand Down
176 changes: 176 additions & 0 deletions tests/test_share_and_copy_link.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion theme/blog-list.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ <h3 class="post_link">
class="facebook" title="Share on Facebook" target="_blank" rel="nofollow noopener">
<svg class="icon"><use xlink:href="#facebook"/></svg>
</a>
<a href="#linkedinshare" class="linkedin" title="Share on LinkedIn" rel="nofollow">
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ url }}"
class="linkedin" title="Share on LinkedIn" target="_blank" rel="nofollow noopener">
<svg class="icon"><use xlink:href="#linkedin"/></svg>
</a>
<a href="{{ url }}" title="Copy Link" class="link link_yank">
Expand Down
3 changes: 2 additions & 1 deletion theme/blog-post.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ <h1>{{ title }}</h1>
class="facebook" title="Share on Facebook" target="_blank" rel="nofollow noopener">
<svg class="icon" aria-hidden="true"><use xlink:href="#facebook"/></svg>
</a>
<a href="#linkedinshare" id="linkedinshare" class="linkedin" title="Share on LinkedIn" rel="nofollow">
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ page.canonical_url or url }}"
class="linkedin" title="Share on LinkedIn" target="_blank" rel="nofollow noopener">
<svg class="icon" aria-hidden="true"><use xlink:href="#linkedin"/></svg>
</a>
<a href="{{ page.canonical_url or url }}" title="Copy link" class="link link_yank">
Expand Down
41 changes: 41 additions & 0 deletions theme/css/blog.css
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,47 @@ a.post-thumb img,
.post_share a:hover{ background: rgba(255,255,255,.08); }
.post_share .share_label{ margin-right: .25rem; }

/* Copy link feedback */
.link_yank {
position: relative;
cursor: pointer;
}

.link_yank.copied {
color: var(--brand-strong, #087fa8) !important;
}

.link_yank .copy-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--surface-solid, #1e293b);
color: var(--fg, #ffffff);
border: 1px solid var(--border, rgba(255, 255, 255, 0.15));
font-size: 0.72rem;
font-weight: 600;
line-height: 1.2;
padding: 0.2rem 0.45rem;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
z-index: 1000;
box-shadow: var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.2));
animation: copyFadeIn 0.15s ease forwards;
}

@keyframes copyFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 1024px){
.post-grid{ --card-min: 280px; }
Expand Down
Loading
Loading