Skip to content

Conversation

@gocanto
Copy link
Contributor

@gocanto gocanto commented Sep 25, 2025

Summary

  • introduce a lazy link directive that prefetches route components when links become interactive and register it app-wide
  • apply the lazy link directive across router links and anchors while enabling lazy loading/async decoding on media assets
  • expand SEO helpers and page metadata with richer keywords, structured data, and default head tags

Testing

  • npm run build (fails: vite CLI is unavailable in the execution environment)

https://chatgpt.com/codex/tasks/task_e_68d4f6fc91c883339df37e0b0528f12f

Summary by CodeRabbit

  • New Features
    • Faster navigation via site-wide lazy link prefetching and image lazy-loading.
    • Enhanced SEO: keywords support, image alt for previews, locale/language and hreflang alternates, richer JSON-LD (Person plus page-specific schemas), and expanded Twitter metadata.
  • Refactor
    • Updated pages and navigation to use the new lazy-link behavior consistently.
  • Tests
    • Added comprehensive tests covering visibility-triggered prefetch, event-driven prefetch, cleanup, fallbacks, and caching.

@coderabbitai
Copy link

coderabbitai bot commented Sep 25, 2025

Caution

Review failed

The pull request is closed.

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds a new lazy-link Vue directive for prefetching routes and registers it app-wide. Broadly enhances SEO: expands head metadata, introduces keywords, imageAlt, locale, hreflang alternates, and Person JSON-LD. Updates multiple pages/partials to use v-lazy-link and image loading attributes; enriches page-level useSeo configs. Adds comprehensive directive tests.

Changes

Cohort / File(s) Summary of changes
SEO core & head metadata
index.html, src/support/seo.ts
Expanded head meta (keywords, hreflang, og locale, Twitter fields). New SEO utilities and options: DEFAULT_DESCRIPTION/KEYWORDS, PERSON_JSON_LD, JsonLd type, buildKeywords, locale/siteLanguage/imageAlt/keywords/jsonLd handling; alternate links and structured data updates.
Lazy-link directive & wiring
src/support/lazy-loading.ts, src/main.ts, tests/support/lazy-loading.test.ts
New lazyLinkDirective for prefetching internal routes on visibility/idle/hover; lifecycle cleanup. Registered as global directive. Added extensive unit tests with mocked IO/idle callbacks and router interactions.
Pages — SEO enhancements
src/pages/HomePage.vue, src/pages/AboutPage.vue, src/pages/ProjectsPage.vue, src/pages/ResumePage.vue, src/pages/SubscribePage.vue
useSeo updated to include imageAlt, keywords via buildKeywords, and jsonLd arrays adding PERSON_JSON_LD; enriched descriptions/urls. Imports updated accordingly.
Pages — lazy links and media loading
src/pages/PostPage.vue, src/pages/SubscribePage.vue
Applied v-lazy-link to navigation/share/external anchors; added image loading attributes; reorganized imports (PostPage) for markdown/highlighter utilities.
Partials — lazy links
src/partials/ArticleItemPartial.vue, src/partials/ArticlesListPartial.vue, src/partials/FeaturedProjectsPartial.vue, src/partials/ProjectCardPartial.vue, src/partials/SideNavPartial.vue, src/partials/TalksPartial.vue, src/partials/WidgetSocialPartial.vue
Replaced anchors with v-lazy-link across items; maintained href/targets; updated active class handling in SideNav; minor formatting.
Partials — image loading props/attrs
src/partials/AvatarPartial.vue, src/partials/ProjectCardPartial.vue, src/partials/RecommendationPartial.vue, src/partials/TalksPartial.vue, src/partials/SideNavPartial.vue
Added loading/decoding/fetchpriority attributes or props. AvatarPartial now exposes configurable props with defaults.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant A as <rect fill="#eef7ff" stroke="#aac8f0">Anchor (v-lazy-link)</rect>
  participant IO as <rect fill="#eef7ff" stroke="#aac8f0">IntersectionObserver</rect>
  participant B as Browser Idle/Timers
  participant R as Router
  participant C as Route Components

  User->>A: Render anchor (mounted)
  A->>IO: Observe element (if supported)
  A->>B: Schedule idle prefetch fallback
  Note over A,IO: Also attaches pointerenter/focus/touchstart

  IO-->>A: Element visible
  A->>R: resolve(href)
  R-->>A: Matched route records
  A->>C: Trigger dynamic imports (prefetch)
  C-->>A: Prefetched (cached)

  User->>A: Hover/Focus (optional)
  A->>B: One-time idle prefetch (if not yet done)

  User->>A: Navigate click
  A->>R: Normal navigation (unchanged)

  A-->>A: Unmounted -> cleanup observers/listeners/timers
Loading
sequenceDiagram
  autonumber
  participant P as Page (useSeo)
  participant SEO as <rect fill="#f6f4ff" stroke="#c9c0f5">Seo.apply</rect>
  participant H as Document Head
  participant L as <rect fill="#f6f4ff" stroke="#c9c0f5">hreflang builder</rect>

  P->>SEO: options { title, description, image, imageAlt, keywords, locale, siteLanguage, jsonLd[] }
  SEO->>H: Set meta tags (title/desc/og/twitter)
  SEO->>H: Set link rel=canonical
  SEO->>H: Set twitter:creator/site if provided
  SEO->>H: Insert JSON-LD (Person + Page)
  SEO->>H: Set html lang/siteLanguage
  SEO->>L: Compute alternates
  L-->>H: link rel="alternate" hreflang=*
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Suggested labels

codex

Poem

A hop, a prefetch, a whisper of speed,
I tap my links softly—only fetch what I need.
Stars of JSON-LD twinkle in meta-night,
Keywords in clover, hreflang in sight.
With lazy little leaps and alt-text cheer,
This bunny ships smooth, from there to here. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly describes the two primary enhancements of the pull request—improvements to media lazy loading and enriched SEO metadata—matching the stated objectives and summary without unnecessary detail or noise.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 13b1ba8 and 80788ef.

📒 Files selected for processing (14)
  • src/main.ts (1 hunks)
  • src/pages/AboutPage.vue (4 hunks)
  • src/pages/HomePage.vue (2 hunks)
  • src/pages/PostPage.vue (6 hunks)
  • src/pages/ProjectsPage.vue (2 hunks)
  • src/pages/ResumePage.vue (2 hunks)
  • src/pages/SubscribePage.vue (4 hunks)
  • src/partials/AvatarPartial.vue (2 hunks)
  • src/partials/FeaturedProjectsPartial.vue (1 hunks)
  • src/partials/SideNavPartial.vue (5 hunks)
  • src/partials/TalksPartial.vue (1 hunks)
  • src/support/lazy-loading.ts (1 hunks)
  • src/support/seo.ts (9 hunks)
  • tests/support/lazy-loading.test.ts (1 hunks)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gocanto gocanto marked this pull request as draft September 25, 2025 08:21
@gemini-code-assist
Copy link

Summary of Changes

Hello @gocanto, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request focuses on optimizing the website's performance and search engine discoverability. It introduces a smart prefetching mechanism for navigation links and defers image loading, while simultaneously enriching the site's metadata with more detailed keywords and structured data. These changes collectively aim to provide a smoother, faster user experience and improve the site's ranking and presentation in search engine results.

Highlights

  • Lazy Link Directive: Introduced a new Vue custom directive, v-lazy-link, which intelligently prefetches route components when links become interactive (e.g., on hover, focus, touch, or when entering the viewport). This significantly improves perceived loading performance by preparing future navigation targets in advance.
  • Media Lazy Loading: Implemented lazy loading and asynchronous decoding for various media assets (images) across the application. This defers the loading of off-screen images until they are needed, reducing initial page load times and improving user experience.
  • Enhanced SEO Metadata: Expanded and refined the website's SEO helpers and page metadata. This includes adding richer keywords, more comprehensive structured data (JSON-LD) with Person schema, default head tags, and hreflang attributes, aiming for better search engine visibility and understanding.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant improvements to lazy loading and SEO metadata. A new v-lazy-link directive is added to prefetch route components, and media assets are now lazy-loaded to improve performance. The SEO metadata has been greatly expanded with more keywords, structured data, and default tags.

My review focuses on a few key areas to ensure these performance and SEO improvements are implemented optimally:

  • I've identified a regression in the SEO helper functions that could lead to stale meta tags.
  • There are a couple of instances where new lazy-loading attributes might negatively impact the Largest Contentful Paint (LCP) for critical images.

Overall, this is a great set of enhancements. Addressing these few points will make it even better.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/partials/SideNavPartial.vue (1)

97-105: Bug: Array.of(constraint).includes(fullPath) is incorrect

Array.of(constraint) wraps the array, so includes(fullPath) always fails. Since you can rely on isExactActive for Home, remove this helper to avoid future misuse.

-function applyClassIf(constraint: string[]): boolean {
-  if (isHome.value) {
-    return true;
-  }
-
-  const fullPath = currentRoute.value.fullPath;
-
-  return Array.of(constraint).includes(fullPath);
-}
src/pages/PostPage.vue (2)

43-57: URL-encode share intent parameters

post.title and the URL should be encoded to avoid malformed links for spaces/special chars.

-const xURLFor = (post: PostResponse) => {
-  return `https://x.com/intent/tweet?url=${fullURLFor(post)}&text=${post.title}`;
-};
+const xURLFor = (post: PostResponse) => {
+  const url = encodeURIComponent(fullURLFor(post));
+  const text = encodeURIComponent(post.title);
+  return `https://x.com/intent/tweet?url=${url}&text=${text}`;
+};

75-87: Use a <button> for the in-page share action (not an anchor)

This is an action, not navigation. Also avoids any interference from v-lazy-link.

-                                                <a
-                                                        v-lazy-link
-                                                        class="flex justify-center items-center text-slate-400 dark:text-slate-500 hover:text-fuchsia-500 dark:hover:text-teal-600 transition duration-150 ease-in-out"
-                                                        href="#"
-                                                        aria-label="Share"
-                                                        @click.prevent="sharePost(post)"
-                                                >
+                                                <button
+                                                        type="button"
+                                                        class="flex justify-center items-center text-slate-400 dark:text-slate-500 hover:text-fuchsia-500 dark:hover:text-teal-600 transition duration-150 ease-in-out"
+                                                        aria-label="Share"
+                                                        @click="sharePost(post)"
+                                                >
 ...
-                                                </a>
+                                                </button>
🧹 Nitpick comments (16)
src/partials/AvatarPartial.vue (1)

2-9: Add intrinsic width/height to prevent CLS; confirm this avatar isn’t LCP-critical.

  • The Tailwind size classes won’t reserve intrinsic space. Add width/height attributes to the img to avoid layout shifts.
  • If this avatar can be above the fold, consider not using loading="lazy" (and fetchpriority="low") to avoid delaying LCP.

Example template change:

 <img
   :class="className"
   :src="props.avatar"
   :alt="props.alt"
+  :width="avatarWidth"
+  :height="avatarHeight"
   loading="lazy"
   decoding="async"
   fetchpriority="low"
/>

Add in <script setup>:

// map to pixels (defaults shown)
const avatarWidth = 80;
const avatarHeight = 80;
// or promote numeric props for intrinsic size if variable per use-site
index.html (4)

8-8: Meta keywords have negligible SEO value today.

Safe to keep, but don’t rely on them for rankings; consider removing to reduce noise.


21-24: Use absolute URLs for Open Graph images and add site_name.

Relative OG images may be ignored by crawlers; include og:site_name.

Apply:

-<meta property="og:url" content="%VITE_SITE_URL%/" />
-<meta property="og:image" content="/favicon.ico" />
+<meta property="og:url" content="%VITE_SITE_URL%/" />
+<meta property="og:image" content="%VITE_SITE_URL%/favicon.ico" />
+<meta property="og:site_name" content="Gustavo Ocanto" />

26-31: Use absolute Twitter image URL and include alt.

Twitter summary cards expect an absolute image and benefit from image alt.

Apply:

-<meta name="twitter:image" content="/favicon.ico" />
+<meta name="twitter:image" content="%VITE_SITE_URL%/favicon.ico" />
+<meta name="twitter:image:alt" content="Gustavo Ocanto — Personal site" />

33-57: Enrich JSON-LD: add WebSite SearchAction and Person image.

Improves SERP features and entity understanding.

Example within the existing array:

 {
   "@context": "https://schema.org",
   "@type": "WebSite",
   "url": "%VITE_SITE_URL%/",
   "name": "Gustavo Ocanto",
   "description": "Personal Website of Gustavo Ocanto, Engineering Leader, AI Architect, and Software Engineer."
+  ,"potentialAction": {
+    "@type": "SearchAction",
+    "target": "%VITE_SITE_URL%/search?q={search_term_string}",
+    "query-input": "required name=search_term_string"
+  }
 },
 {
   "@context": "https://schema.org",
   "@type": "Person",
   "name": "Gustavo Ocanto",
   "url": "%VITE_SITE_URL%/",
   "jobTitle": "Engineering Leader, AI Architect, Software Engineer",
   "description": "Engineering leader and AI architect sharing expertise in software, leadership, and product building.",
+  "image": "%VITE_SITE_URL%/og-image.jpg",
   "sameAs": [
     "https://x.com/gocanto",
     "https://www.linkedin.com/in/gocanto/",
     "https://github.com/gocanto"
   ]
 }
src/partials/FeaturedProjectsPartial.vue (1)

7-14: Confirm directive behavior for external links.

Ensure no prefetch for off-site URLs.

If the directive can’t detect externals reliably, restrict v-lazy-link usage to internal navigation only in this loop.

src/partials/ArticlesListPartial.vue (1)

8-15: Don’t use v-lazy-link on non-navigational controls; prefer a button.

This anchor doesn’t navigate (href="#", click.prevent). Using a button improves semantics/a11y and avoids unnecessary directive work.

Apply:

-<a
-  v-lazy-link
-  href="#"
+<button
+  type="button"
   :class="
     filters.category === category.slug
       ? 'text-slate-800 border-fuchsia-500 dark:text-slate-200 dark:border-teal-500'
       : 'text-slate-500 border-transparent hover:border-slate-300 dark:text-slate-300 dark:hover:border-slate-700'
   "
   class="block py-3 font-medium border-b-2"
   @click.prevent="selectCategory(category.slug)"
-  >{{ category.name }}</a>
+  >{{ category.name }}</button>
src/partials/ArticleItemPartial.vue (1)

31-36: Secondary arrow link is unfocusable; add aria-hidden to avoid duplicate announcement

Since this duplicate link is tabindex="-1", add aria-hidden="true" to prevent screen readers from announcing it twice.

-                                                tabindex="-1"
+                                                tabindex="-1"
+                                                aria-hidden="true"
src/partials/TalksPartial.vue (1)

16-23: Tailwind arbitrary size values missing units

max-w-[336] / max-h-[189] won’t compile; add units.

-                                        class="absolute inset-0 w-full h-full object-cover opacity-40 max-w-[336] max-h-[189]"
+                                        class="absolute inset-0 w-full h-full object-cover opacity-40 max-w-[336px] max-h-[189px]"
src/pages/AboutPage.vue (1)

63-73: Don’t apply v-lazy-link to mailto: links

Prefetching/parsing isn’t applicable to non-HTTP schemes; remove the directive here.

-                                                                                                        v-lazy-link
                                                                                                         class="blog-link"
src/pages/SubscribePage.vue (2)

123-128: Remove v-lazy-link on non-navigational anchors.

href="#0" is ignored by the directive (normalized to null), so the directive adds no value here. Either remove the directive or convert these to buttons.

Apply this diff:

-        <a
-                v-lazy-link
+        <a
                 class="rounded-lg border border-slate-200 dark:border-slate-800 dark:bg-linear-to-t dark:from-slate-800 dark:to-slate-800/30 odd:-rotate-1 even:rotate-1 hover:rotate-0 transition-transform duration-700 hover:duration-100 ease-in-out p-5"
                 href="#0"
         >
...
-        <a
-                v-lazy-link
+        <a
                 class="rounded-lg border border-slate-200 dark:border-slate-800 dark:bg-linear-to-t dark:from-slate-800 dark:to-slate-800/30 odd:-rotate-1 even:rotate-1 hover:rotate-0 transition-transform duration-700 hover:duration-100 ease-in-out p-5"
                 href="#0"
         >

Also applies to: 149-154


130-139: Align testimonial images with public asset serving.

Same note as above: prefer /images/... (public) or imported assets to ensure reliable bundling and caching.

Also applies to: 156-165

src/support/lazy-loading.ts (3)

59-60: Normalize without hash to avoid duplicate prefetch/cache entries.

Hashes don’t change route components. Dropping them prevents redundant prefetches for the same path with different fragments.

Apply this diff:

-                return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+                return `${parsed.pathname}${parsed.search}`;

75-94: Debounce prefetch and avoid duplicate dynamic imports.

  • Add to the prefetched set before resolving to prevent concurrent duplicate scheduling.
  • Catch import rejections to avoid unhandled promise warnings.

Apply this diff:

 function prefetchRoute(href: string): void {
   if (!isBrowser) {
     return;
   }

-  if (prefetchedRoutes.has(href)) {
+  if (prefetchedRoutes.has(href)) {
     return;
   }

   try {
+    // Mark as prefetched early to avoid duplicate concurrent work
+    prefetchedRoutes.add(href);
+
     const resolved = router.resolve(href);

     resolved.matched.forEach((record) => {
       const components = record.components ?? (record.component ? { default: record.component } : undefined);

       if (!components) {
         return;
       }

       Object.values(components).forEach((component) => {
         if (typeof component === 'function') {
           // Trigger the dynamic import for the route component
-          void component();
+          void component().catch((err: unknown) => {
+            // avoid unhandled promise rejections; keep noise minimal
+            console.warn('Lazy link prefetch failed for', href, err);
+          });
         }
       });
     });
-
-    prefetchedRoutes.add(href);
+    // already added above
   } catch (error) {
     console.error('Unable to prefetch route component for link', href, error);
   }
 }

112-132: Guard against repeated idle scheduling.

Avoid stacking multiple idle callbacks when several triggers fire before the first callback runs.

Apply this diff:

   const schedulePrefetch = (): void => {
-    if (!isBrowser || prefetchedRoutes.has(href)) {
+    if (!isBrowser || prefetchedRoutes.has(href)) {
       return;
     }
 
     const runPrefetch = () => {
       idleHandle = null;
       prefetchRoute(href);
       element.dataset.lazyLink = 'prefetched';
     };
 
-    if (idleCallback) {
+    if (idleCallback) {
+      if (idleHandle !== null) return;
       idleHandle = idleCallback(() => {
         runPrefetch();
       }, { timeout: 1500 });
     } else {
+      if (idleHandle !== null) return;
       idleHandle = window.setTimeout(() => {
         runPrefetch();
       }, 120);
     }
   };
src/support/seo.ts (1)

309-336: Avoid duplicating keyword normalization logic.

normalizeKeywords and buildKeywords overlap. Consider reusing one to reduce maintenance.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 09b486a and 5c9101c.

📒 Files selected for processing (19)
  • index.html (1 hunks)
  • src/main.ts (1 hunks)
  • src/pages/AboutPage.vue (4 hunks)
  • src/pages/HomePage.vue (1 hunks)
  • src/pages/PostPage.vue (5 hunks)
  • src/pages/ProjectsPage.vue (1 hunks)
  • src/pages/ResumePage.vue (2 hunks)
  • src/pages/SubscribePage.vue (4 hunks)
  • src/partials/ArticleItemPartial.vue (1 hunks)
  • src/partials/ArticlesListPartial.vue (1 hunks)
  • src/partials/AvatarPartial.vue (1 hunks)
  • src/partials/FeaturedProjectsPartial.vue (1 hunks)
  • src/partials/ProjectCardPartial.vue (1 hunks)
  • src/partials/RecommendationPartial.vue (1 hunks)
  • src/partials/SideNavPartial.vue (5 hunks)
  • src/partials/TalksPartial.vue (1 hunks)
  • src/partials/WidgetSocialPartial.vue (1 hunks)
  • src/support/lazy-loading.ts (1 hunks)
  • src/support/seo.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main.ts (1)
src/support/lazy-loading.ts (1)
  • lazyLinkDirective (204-242)
🔇 Additional comments (24)
src/main.ts (1)

15-16: Directive registration looks good.

Global registration before mount is correct and matches directive’s browser guard.

src/partials/RecommendationPartial.vue (1)

11-20: LGTM: image lazy-loading with intrinsic size.

Attributes are appropriate and accessibility preserved via meaningful alt.

src/partials/ProjectCardPartial.vue (2)

2-8: Check external-link handling in v-lazy-link.

Same consideration as other external anchors; avoid unnecessary prefetch.

See the script in WidgetSocialPartial comment to verify guards apply here too.


13-21: LGTM: icon image lazy-loading.

Appropriate attributes; alt derived from item.title.

index.html (1)

12-14: Ensure VITE_SITE_URL is defined in all deploy environments
No VITE_SITE_URL entry found in any .env*—add it to your local .env files and CI/deploy settings so the HTML canonical and hreflang links interpolate correctly.

src/partials/ArticleItemPartial.vue (1)

18-25: Using a directive on <router-link>: confirm directive handles component roots correctly

Vue applies directives on a component’s root element. Ensure v-lazy-link correctly binds to <router-link>’s rendered <a> and doesn’t interfere with its click/keyboard handling.

src/partials/SideNavPartial.vue (4)

6-8: LGTM on applying v-lazy-link to the avatar link


36-46: LGTM on lazy-link usage with custom router-link slots


51-59: LGTM


64-72: LGTM

src/pages/PostPage.vue (2)

18-27: LGTM on back link with lazy prefetch


59-73: LGTM on external share link; ensure directive no-ops for externals

Verify v-lazy-link safely no-ops on external links (target="_blank") and doesn’t attempt route prefetch.

src/pages/HomePage.vue (2)

56-56: LGTM on expanded SEO imports


62-77: Confirm array support in useSeo for jsonLd and keywords
Ensure the hook’s types and rendering logic accept arrays for both props (emitting multiple <script type="application/ld+json"> tags and correctly processing keyword lists).

src/partials/TalksPartial.vue (1)

7-15: External links: verify v-lazy-link no-ops

Ensure the directive does not attempt SPA prefetching for external targets.

src/pages/ResumePage.vue (2)

58-58: LGTM on SEO imports


68-83: Verify jsonLd array handling in useSeo
Inspect setJsonLd in src/support/seo.ts: ensure it accepts an array of JSON-LD objects (not just a single object), merges or replaces correctly, and deduplicates entries when multiple calls occur.

src/pages/ProjectsPage.vue (2)

68-68: LGTM on SEO imports


76-91: SEO JSON-LD: verify CollectionPage type and properties
Confirm that schema.org @type “CollectionPage” is the correct choice for /projects and that your SEO renderer supports the properties name, url and description on it.

src/pages/AboutPage.vue (1)

35-43: External tech links: confirm directive ignores non-internal URLs

Ensure v-lazy-link limits itself to same-origin/router links to avoid unnecessary work on external anchors.

src/pages/SubscribePage.vue (2)

201-220: SEO config upgrade looks solid.

Good use of imageAlt, keywords via buildKeywords, and array-based JSON‑LD including PERSON_JSON_LD.


63-112: Verify asset path resolution for avatars.

Relative paths like ../images/avatar-01.jpg may not resolve in Vite unless they’re in src and handled by the bundler. Prefer public assets via absolute paths (/images/...) or import the assets.

Would you confirm these files exist under public/images? If not, I can generate a quick codemod to switch to absolute paths.

src/support/lazy-loading.ts (1)

204-242: Directive lifecycle and cleanup logic look good.

State tracking via WeakMap, idempotent update handling, and full cleanup on unmount are well done.

src/support/seo.ts (1)

7-36: Good SEO defaults and reusable PERSON_JSON_LD.

Solid base description/keywords and shared Person schema for reuse across pages.

@gocanto gocanto added testing and removed codex labels Sep 25, 2025
@gocanto gocanto marked this pull request as ready for review September 25, 2025 09:39
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (16)
src/partials/SideNavPartial.vue (3)

5-15: Ensure directive actually binds to the anchor; consider adding an accessible label.

Using v-lazy-link directly on generally applies to its root , but this isn’t guaranteed for multi-root components. For consistency with other items and to guarantee the directive attaches to the anchor, prefer the custom slot.

Also add an aria-label for screen readers.

-<router-link v-lazy-link to="/">
-        <AvatarPartial
-                width="w-16"
-                height="h-16"
-                loading="lazy"
-                decoding="async"
-                fetchpriority="low"
-        />
-</router-link>
+<router-link v-slot="{ href, navigate }" to="/" custom>
+  <a v-lazy-link :href="href" @click="navigate" aria-label="Home">
+    <AvatarPartial
+      width="w-16"
+      height="h-16"
+      loading="lazy"
+      decoding="async"
+      fetchpriority="low"
+    />
+  </a>
+</router-link>

43-52: Add aria-current to all nav anchors for accessibility.

Mirror the Home item a11y improvement for About/Projects/Resume.

-<a v-lazy-link class="h6 blog-side-nav-router-link-a" :class="bindIconClassFor(isExactActive)" :href="href" @click="navigate">
+<a v-lazy-link class="h6 blog-side-nav-router-link-a" :class="bindIconClassFor(isExactActive)" :href="href" @click="navigate" :aria-current="isExactActive ? 'page' : null">

Also applies to: 58-66, 71-79


95-101: Use path instead of fullPath to make home detection robust against query/hash.

-return currentRoute.value.fullPath === '/';
+return currentRoute.value.path === '/';
src/support/lazy-loading.ts (4)

28-73: Harden href normalization (protocol-relative and data/blob URIs).

Add guards for protocol-relative URLs (//example.com) and data/blob URIs to avoid false-positive prefetches.

- if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('mailto:') || trimmed.startsWith('tel:') || trimmed.startsWith('javascript:')) {
+ if (
+   trimmed === '' ||
+   trimmed.startsWith('#') ||
+   trimmed.startsWith('mailto:') ||
+   trimmed.startsWith('tel:') ||
+   trimmed.startsWith('javascript:') ||
+   trimmed.startsWith('//') || // protocol-relative external URL
+   /^data:|^blob:/.test(trimmed) // non-navigational URIs
+ ) {
   return null;
 }

121-144: Prevent duplicate scheduling when multiple triggers fire before idle runs.

If both intersection and a pointer/focus event fire quickly, you can enqueue multiple idle jobs. Track a per-element scheduled flag and skip re-scheduling.

-  let idleHandle: number | null = null;
+  let idleHandle: number | null = null;
+  let scheduled = false;

   const schedulePrefetch = (): void => {
-    if (!isBrowser || prefetchedRoutes.has(href)) {
+    if (!isBrowser || scheduled || prefetchedRoutes.has(href)) {
       return;
     }
+    scheduled = true;

     const runPrefetch = () => {
       idleHandle = null;
+      scheduled = false;
       prefetchRoute(href);
       element.dataset.lazyLink = 'prefetched';
     };

And in cleanup:

   cleanupFns.push(() => {
     if (idleHandle !== null) {
       if (cancelIdleCallback) {
         cancelIdleCallback(idleHandle);
       } else {
         window.clearTimeout(idleHandle);
       }

       idleHandle = null;
     }
+    scheduled = false;
   });

80-106: Cache by route path (not full href) to avoid duplicate prefetch across query/hash variants.

The same route component is used regardless of query/hash; caching by resolved.path reduces redundant work.

Consider:

  • Compute const key = resolved.path and use it for the Set.
  • Optionally maintain a small in-flight Set to short-circuit concurrent scheduling.

148-169: Consider a shared IntersectionObserver to reduce per-link allocations.

Creating one observer per link is OK at small scale; a shared observer with a registry can cut allocations and overhead on content-heavy pages.

src/partials/ProjectCardPartial.vue (2)

3-3: Unnecessary directive on external links.

This anchor is target="_blank" to external URLs; the directive immediately ignores it. You can drop v-lazy-link here.

-  <a
-    v-lazy-link
+  <a

13-13: Alt text clarity (nit).

Consider a more descriptive alt like “{{ item.title }} icon” if appropriate.

tests/support/lazy-loading.test.ts (1)

139-176: Great coverage of intersection + idle flow. Add a race test if feasible.

A quick follow-up test asserting no double scheduling when both intersection and pointerenter fire before idle runs would lock in the de-dupe change.

src/partials/ArticlesListPartial.vue (1)

9-19: Drop v-lazy-link for non-navigational filters.

These anchors have href="#" and are handled via click.prevent; the directive will always ignore them. Remove to reduce template noise.

-        <a
-          v-lazy-link
+        <a
           href="#"
src/pages/ProjectsPage.vue (1)

68-68: Enrich CollectionPage JSON-LD with ItemList (optional)

Consider adding an ItemList (itemListElement of ListItem with position/url/name) to the CollectionPage for stronger SERP hints.

Also applies to: 79-91

src/pages/AboutPage.vue (1)

28-35: Guard v-lazy-link on external/mailto anchors

If the directive prefetches Vue routes, it should no-op for external/http(s) and mailto links to avoid unnecessary work or errors.

If you prefer to keep logic purely in the directive, ensure it checks el instanceof HTMLAnchorElement and skips when:

  • href starts with http(s):// and host !== current host
  • href starts with mailto:, tel:, or is just a hash

Example guard (in the directive):

const href = el.getAttribute('href') ?? '';
const isExternal = /^https?:\/\//i.test(href) && new URL(href, location.href).host !== location.host;
const isSpecial = /^(mailto:|tel:|#)/i.test(href);
if (isExternal || isSpecial) return; // skip prefetch

Also applies to: 56-58

src/pages/SubscribePage.vue (1)

124-139: Avoid v-lazy-link on non-navigational anchors

These href="#0" anchors don’t navigate; using the directive here adds overhead. Prefer button elements or remove the directive.

-<a v-lazy-link ... href="#0">
+<button type="button" ...>
   ...
-</a>
+</button>

Also applies to: 150-165

src/support/seo.ts (2)

85-86: Meta name="language" is nonstandard (optional)

html[lang] already conveys language; consider dropping the meta to reduce noise.

-		this.setMetaByName('language', language);

299-316: Schema.org Article.keywords should be Text

Prefer a comma-separated string over an array for JSON-LD validity.

-					keywords: tagKeywords,
+					keywords: tagKeywords.join(', '),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c9101c and 13b1ba8.

📒 Files selected for processing (24)
  • .github/workflows/deploy.yml (1 hunks)
  • docker-compose.yml (1 hunks)
  • index.html (2 hunks)
  • src/css/style.css (3 hunks)
  • src/css/support/blog.css (3 hunks)
  • src/css/support/theme.css (1 hunks)
  • src/pages/AboutPage.vue (4 hunks)
  • src/pages/HomePage.vue (1 hunks)
  • src/pages/PostPage.vue (5 hunks)
  • src/pages/ProjectsPage.vue (2 hunks)
  • src/pages/ResumePage.vue (2 hunks)
  • src/pages/SubscribePage.vue (4 hunks)
  • src/partials/ArticleItemPartial.vue (2 hunks)
  • src/partials/ArticlesListPartial.vue (1 hunks)
  • src/partials/AvatarPartial.vue (1 hunks)
  • src/partials/FeaturedProjectsPartial.vue (1 hunks)
  • src/partials/ProjectCardPartial.vue (2 hunks)
  • src/partials/RecommendationPartial.vue (1 hunks)
  • src/partials/SideNavPartial.vue (5 hunks)
  • src/partials/TalksPartial.vue (1 hunks)
  • src/partials/WidgetSocialPartial.vue (1 hunks)
  • src/support/lazy-loading.ts (1 hunks)
  • src/support/seo.ts (9 hunks)
  • tests/support/lazy-loading.test.ts (1 hunks)
✅ Files skipped from review due to trivial changes (4)
  • .github/workflows/deploy.yml
  • docker-compose.yml
  • src/css/support/theme.css
  • src/css/support/blog.css
🚧 Files skipped from review as they are similar to previous changes (8)
  • src/partials/AvatarPartial.vue
  • src/partials/RecommendationPartial.vue
  • src/pages/ResumePage.vue
  • index.html
  • src/pages/PostPage.vue
  • src/partials/TalksPartial.vue
  • src/partials/WidgetSocialPartial.vue
  • src/partials/FeaturedProjectsPartial.vue
🧰 Additional context used
🪛 ast-grep (0.39.5)
tests/support/lazy-loading.test.ts

[warning] 87-87: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.body.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)


[warning] 88-88: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.head.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

🪛 Biome (2.1.2)
src/css/style.css

[error] 25-25: Unknown property is not allowed.

See CSS Specifications and browser specific properties for more details.
To resolve this issue, replace the unknown property with a valid CSS property.

(lint/correctness/noUnknownProperty)

🔇 Additional comments (14)
src/css/style.css (3)

11-13: Consistent Tailwind import ordering looks good

The switch to single-quoted imports keeps the Tailwind layers tidy and consistent with the surrounding style sheet.


25-25: Plugin configuration formatting is tidy

Indenting the strategy declaration keeps the plugin block easy to scan alongside the rest of the file.


76-86: Color token formatting remains consistent

The updated spacing on the fuchsia palette maintains the existing values while matching the file’s indentation style.

src/partials/SideNavPartial.vue (2)

23-37: LGTM; using isExactActive is the right call. Add aria-current for a11y.

This addresses the prior suggestion to rely on isExactActive. Add aria-current to indicate the active page to assistive tech.

-        <a
+        <a
           v-lazy-link
           class="h6 blog-side-nav-router-link-a"
           :class="bindIconClassFor(isExactActive)"
           :href="href"
           @click="navigate"
+          :aria-current="isExactActive ? 'page' : null"
         >

103-105: LGTM; simple and clear class binding helper.

tests/support/lazy-loading.test.ts (1)

80-90: innerHTML resets flagged by static analysis are acceptable in tests.

These resets are safe here since content is not user-provided. No action needed.

src/partials/ArticleItemPartial.vue (1)

20-25: LGTM: lazy-link on RouterLink

Usage looks correct and non-invasive.

Also applies to: 32-40

src/pages/AboutPage.vue (2)

91-91: LGTM: SEO imports and richer metadata

The additions (imageAlt, keywords via buildKeywords, and PERSON_JSON_LD) are coherent.

Also applies to: 115-127


20-20: Hero image should be eager/high priority for LCP

Above-the-fold image should be loading="eager" and fetchpriority="high"; also add intrinsic width/height to prevent CLS.

Apply:

-<img class="rounded-lg w-full mb-5" :src="aboutPicture" :alt="`Portrait of: ${formattedNickname}`" loading="lazy" decoding="async" fetchpriority="low" />
+<img class="rounded-lg w-full mb-5" :src="aboutPicture" :alt="`Portrait of: ${formattedNickname}`" loading="eager" decoding="async" fetchpriority="high" width="1280" height="720" />

Adjust width/height to the image’s actual intrinsic size.

src/pages/HomePage.vue (1)

56-77: LGTM: Expanded SEO config

imageAlt, keywords, and multi-entry jsonLd are consistent with seo.ts changes.

src/pages/SubscribePage.vue (2)

69-72: LGTM: Lazy-loading small avatars

Appropriate use of loading/decoding/fetchpriority for non-critical imagery.

Also applies to: 79-82, 89-92, 99-102, 109-112


201-219: LGTM: SEO metadata

Imports and useSeo configuration align with new API.

src/support/seo.ts (2)

96-103: Good: prevent stale OG/Twitter image tags

Conditional set/clear avoids leaking prior page images across SPA navigations.

Please sanity-check a route change from a page with image to one without, to confirm og:image/twitter:image are removed.

Also applies to: 114-120


187-204: LGTM: Single managed JSON-LD script

Scoped by id and fully replaced per page; good SPA hygiene.

@gocanto gocanto marked this pull request as draft September 26, 2025 01:21
@gocanto gocanto removed the testing label Sep 26, 2025
@gocanto gocanto marked this pull request as ready for review September 26, 2025 02:19
@gocanto gocanto merged commit 6a6b82c into main Sep 26, 2025
4 of 5 checks passed
@gocanto gocanto deleted the codex/add-lazy-loading-for-images-and-links branch September 26, 2025 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants