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
4 changes: 2 additions & 2 deletions src/_includes/individual-blog.njk
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% include "seo.njk" %}
<link rel="shortcut icon" href="../assets/logo.svg" type="image/x-icon">
<title>{{ title }}</title>
<link rel="stylesheet" href="/individual.css">
<link rel="alternate" type="application/rss+xml" title="{{ metadata.title }} RSS Feed" href="{{ '/feed.xml' | absoluteUrl(metadata.siteUrl) }}">
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

This duplicates the RSS autodiscovery link already emitted by seo.njk (included on line 6). Keeping both results in two identical <link rel="alternate" ...> tags in the page head; remove one (prefer keeping it centralized in seo.njk).

Suggested change
<link rel="alternate" type="application/rss+xml" title="{{ metadata.title }} RSS Feed" href="{{ '/feed.xml' | absoluteUrl(metadata.siteUrl) }}">

Copilot uses AI. Check for mistakes.
</head>
<body>
{% include 'header.njk' %}
Expand All @@ -15,7 +15,7 @@
<div class="container container--narrow flow">
<h1 class="article-title">{{ title }}</h1>
<figure class="main-article__figure">
<img src="{{ image }}" alt="{{ imageAlt }}">
<img src="{{ image }}" alt="{{ imageAlt }}" width="800" height="450" loading="eager">
<figcaption>{{ imageAlt }}</figcaption>
</figure>

Expand Down
94 changes: 57 additions & 37 deletions src/_includes/seo.njk
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,37 @@
{% set imageAlt = imageAlt or "CourtNotes site logo" %}
{% set absolutePageUrl = page.url | absoluteUrl(metadata.siteUrl) %}
{% set fullImage %}{% if image and image.indexOf('http') == 0 %}{{ image }}{% else %}{{ image | absoluteUrl(metadata.siteUrl) }}{% endif %}{% endset %}



{% set isPost = tags and tags.indexOf('post') > -1 %}

{# Meta tags for SEO #}
<title>{{ seoTitle }}</title>
<meta name="description" content="{{ description }}">
<meta name="author" content="{{ author }}">
<meta name="keywords" content="courtney fradreck, notes, personal, blog, courtney fradreck's blog, reflections, software engineering, learning, tech, growth">
<meta name="robots" content="index, follow">

{# Canonical link #}
<link rel="canonical" href="{{ absolutePageUrl }}">

{# RSS feed discovery #}
<link rel="alternate" type="application/rss+xml" title="{{ metadata.title }} RSS Feed" href="{{ '/feed.xml' | absoluteUrl(metadata.siteUrl) }}">

<!-- Theme Colors for Light and Dark -->
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1c1e" media="(prefers-color-scheme: dark)">

{# Open Graph for social previews #}
<meta property="og:title" content="{{ seoTitle }}">
<meta property="og:description" content="{{ description }}">
<meta property="og:type" content="article">
<meta property="og:type" content="{% if isPost %}article{% else %}website{% endif %}">
<meta property="og:url" content="{{ absolutePageUrl }}">
<meta property="og:image" content="{{ fullImage }}">
<meta property="og:image:alt" content="{{ imageAlt }}">
<meta property="og:site_name" content="CourtNotes by {{ author }}">
{% if isPost %}
<meta property="article:author" content="{{ author }}">
<meta property="article:published_time" content="{{ date.toISOString() if date }}">
{% endif %}

{# Twitter card #}
<meta name="twitter:card" content="summary_large_image">
Expand All @@ -45,47 +51,61 @@
<meta name="twitter:creator" content="@CourtneyFraddy">
<meta name="twitter:site" content="@CourtneyFraddy">



<!-- Schema.org JSON-LD -->
{# Schema.org JSON-LD — conditional by page type #}
{% if isPost %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Blog",
"name": "{{ seoTitle }}",
"url": "{{ absolutePageUrl }}",
"@type": "BlogPosting",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ absolutePageUrl }}"
},
"headline": "{{ title }}",
"description": "{{ description }}",
"image": ["{{ fullImage }}"],
"author": {
"@type": "Person",
"name": "{{ author }}"
},
"publisher": {
"@type": "Organization",
"name": "{{ metadata.title }}",
"logo": {
"@type": "ImageObject",
"url": "{{ '/assets/logo.svg' | absoluteUrl(metadata.siteUrl) }}"
}
},
"datePublished": "{{ date.toISOString() if date else '2024-01-01' }}",
"dateModified": "{{ date.toISOString() if date else '2024-01-01' }}"
}
</script>
{% elif title == "Home" %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "{{ metadata.title }}",
"url": "{{ metadata.siteUrl }}",
"description": "{{ description }}",
"author": {
"@type": "Person",
"name": "{{ metadata.author }}"
}
}
</script>
{% else %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ absolutePageUrl }}"
},
"headline": "{{ seoTitle | safe }}",
"description": "{{ description | safe }}",
"image": ["{{ fullImage }}"],
"author": {
"@type": "Person",
"name": "{{ author or metadata.author }}"
},
"publisher": {
"@type": "Organization",
"name": "{{ seoTitle }}",
"logo": {
"@type": "ImageObject",
"url": "{{ '/assets/logo.svg' | absoluteUrl(metadata.siteUrl) }}"
}
},
"datePublished": "{{ date or '2024-01-01' }}",
"dateModified": "{{ date or '2024-01-01' }}"
}
</script>

{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "{{ seoTitle }}",
"url": "{{ absolutePageUrl }}",
"description": "{{ description }}",
"author": {
"@type": "Person",
"name": "{{ metadata.author }}"
}
}
</script>
{% endif %}
Comment on lines +54 to +111
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "seo.njk" -type f

Repository: CourtneyFradreck/CourtNotes

Length of output: 94


🏁 Script executed:

cat -n ./src/_includes/seo.njk | head -120

Repository: CourtneyFradreck/CourtNotes

Length of output: 4898


🏁 Script executed:

find . -name ".eleventy.js" -o -name "eleventy.config.js" -o -name "*.config.js" | head -5

Repository: CourtneyFradreck/CourtNotes

Length of output: 85


🏁 Script executed:

cat -n ./.eleventy.js

Repository: CourtneyFradreck/CourtNotes

Length of output: 2143


🏁 Script executed:

rg -i "filter|tojson|jsonencode|jsonescape" --type=js . | head -20

Repository: CourtneyFradreck/CourtNotes

Length of output: 415


🏁 Script executed:

cat -n package.json

Repository: CourtneyFradreck/CourtNotes

Length of output: 493


🏁 Script executed:

find src -name "*.md" -type f | head -3 | xargs head -30

Repository: CourtneyFradreck/CourtNotes

Length of output: 5280


🏁 Script executed:

rg -i "nunjucks|njk" package.json

Repository: CourtneyFradreck/CourtNotes

Length of output: 53


🏁 Script executed:

rg "tojson|toJSON" src/ --type=njk

Repository: CourtneyFradreck/CourtNotes

Length of output: 98


🏁 Script executed:

rg "tojson|toJSON" src/

Repository: CourtneyFradreck/CourtNotes

Length of output: 53


Apply JSON escaping to all dynamic values in JSON-LD blocks.

These template variables (title, description, author, metadata.title, metadata.author, seoTitle) are interpolated directly into JSON without encoding. Any double quotes, backslashes, or newlines in these values will produce invalid JSON. Register a Nunjucks filter (e.g., toJSON or escape) that applies JSON.stringify() to these values, or wrap them explicitly with proper JSON encoding.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/_includes/seo.njk` around lines 54 - 111, The JSON-LD blocks
(BlogPosting, WebSite, WebPage) inject unescaped template variables (e.g.,
title, description, author, metadata.title, metadata.author, seoTitle,
fullImage, absolutePageUrl, date) directly into JSON; create/register a Nunjucks
filter (e.g., toJSON) that returns JSON.stringify(value) and then update every
dynamic insertion inside the three conditional scripts (the isPost BlogPosting
block, the Home WebSite block, and the default WebPage block) to pipe the
variables through that filter (e.g., use title | toJSON, description | toJSON,
author | toJSON, metadata.title | toJSON, etc.), and ensure date values are
converted/escaped similarly (date.toISOString() | toJSON or pre-format before
filtering) so all injected values are valid, escaped JSON strings.

2 changes: 1 addition & 1 deletion src/blog.njk
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ layout: base.njk
{%- for post in collections.post | reverse -%}
<li>
<article class="snippet">
<img src="{{ post.data.image }}" alt="{{ post.data.imageAlt }}" class="snippet__image">
<img src="{{ post.data.image }}" alt="{{ post.data.imageAlt }}" class="snippet__image" width="400" height="225" loading="lazy">
<h3 class="snippet__title"><a href="{{ post.url}}">{{ post.data.title }}</a></h3>
<p class="snippet__meta">by <span>{{ post.data.author }}</span> on <span>{{ post.date | postDate}}</span></p>
<p>{{ readingTime }}</p>
Expand Down
24 changes: 24 additions & 0 deletions src/feed.xml.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
permalink: "feed.xml"
eleventyExcludeFromCollections: true
---
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{{ metadata.title }}</title>
<link>{{ metadata.siteUrl }}</link>
<description>Personal blog by {{ metadata.author }} — covering software engineering, startup building, and learning in public.</description>
<language>en</language>
<atom:link href="{{ metadata.siteUrl }}/feed.xml" rel="self" type="application/rss+xml"/>
{%- for post in collections.post | reverse %}
<item>
<title>{{ post.data.title }}</title>
<link>{{ metadata.siteUrl }}{{ post.url }}</link>
<guid isPermaLink="true">{{ metadata.siteUrl }}{{ post.url }}</guid>
<description>{{ post.data.description }}</description>
<pubDate>{{ post.date.toUTCString() }}</pubDate>
Comment on lines +15 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Escape RSS item fields before writing XML.

post.data.title and post.data.description are emitted raw here. A post containing &, <, or > will produce malformed feed.xml, and some readers will reject the feed entirely. Please XML-escape these values, or wrap the description safely in CDATA before rendering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/feed.xml.njk` around lines 15 - 19, Escape XML-sensitive characters when
rendering RSS item fields in src/feed.xml.njk: replace raw uses of
post.data.title and post.data.description with XML-escaped equivalents (e.g.,
helper/filters that escape &, <, >, " and '), or wrap description in a CDATA
block before emitting; ensure link/guid composed from metadata.siteUrl and
post.url remain unchanged but escaped if they can contain special characters,
and leave pubDate as post.date.toUTCString(). Update the template to call the
escape helper (or use CDATA for description) wherever post.data.title or
post.data.description are rendered.

<author>{{ metadata.author }}</author>
Comment on lines +8 to +20
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

RSS XML output needs escaping: at least one post title contains an ampersand (e.g., “AgriLease & Nexpay”), and unescaped & (or <, >, etc.) will produce invalid XML in <title>/<description>. Please XML-escape these fields (or wrap in CDATA) when rendering the feed items.

Suggested change
<title>{{ metadata.title }}</title>
<link>{{ metadata.siteUrl }}</link>
<description>Personal blog by {{ metadata.author }} — covering software engineering, startup building, and learning in public.</description>
<language>en</language>
<atom:link href="{{ metadata.siteUrl }}/feed.xml" rel="self" type="application/rss+xml"/>
{%- for post in collections.post | reverse %}
<item>
<title>{{ post.data.title }}</title>
<link>{{ metadata.siteUrl }}{{ post.url }}</link>
<guid isPermaLink="true">{{ metadata.siteUrl }}{{ post.url }}</guid>
<description>{{ post.data.description }}</description>
<pubDate>{{ post.date.toUTCString() }}</pubDate>
<author>{{ metadata.author }}</author>
<title>{{ metadata.title | escape }}</title>
<link>{{ metadata.siteUrl | escape }}</link>
<description>Personal blog by {{ metadata.author | escape }} — covering software engineering, startup building, and learning in public.</description>
<language>en</language>
<atom:link href="{{ (metadata.siteUrl ~ '/feed.xml') | escape }}" rel="self" type="application/rss+xml"/>
{%- for post in collections.post | reverse %}
<item>
<title>{{ post.data.title | escape }}</title>
<link>{{ (metadata.siteUrl ~ post.url) | escape }}</link>
<guid isPermaLink="true">{{ (metadata.siteUrl ~ post.url) | escape }}</guid>
<description>{{ post.data.description | escape }}</description>
<pubDate>{{ post.date.toUTCString() }}</pubDate>
<author>{{ metadata.author | escape }}</author>

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

<author> in RSS 2.0 items is intended to be an email address (often in the form email (name)); a plain display name may fail validation in stricter feed readers. Consider removing <author>, adding a real email, or switching to dc:creator (with the proper namespace) for a name-only author field.

Copilot uses AI. Check for mistakes.
</item>
{%- endfor %}
</channel>
</rss>
3 changes: 2 additions & 1 deletion src/index.njk
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title : Home
layout: 'base.njk'
description: "CourtNotes — a personal blog by Courtney Fradreck covering software engineering, startup building, and learning in public from Zimbabwe."
---


Expand Down Expand Up @@ -85,7 +86,7 @@ document.addEventListener('DOMContentLoaded', () => {
<button onclick="window.location.href='{{ post.url }}'">Continue Reading</button>
</div>
<div class="featured-article-img">
<img src="{{ post.data.image }}" alt="{{ post.data.imageAlt }}" width="400">
<img src="{{ post.data.image }}" alt="{{ post.data.imageAlt }}" width="400" height="225" loading="lazy">
</div>
</div>
{%- endfor -%}
Expand Down
21 changes: 21 additions & 0 deletions src/llms.txt.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
permalink: "llms.txt"
eleventyExcludeFromCollections: true
---
# {{ site.title }}

> {{ site.description }}
Comment on lines +5 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare site metadata sources to detect inconsistencies

echo "=== Checking .eleventy.js siteMetadata ==="
rg -A 5 'const siteMetadata = \{' .eleventy.js

echo -e "\n=== Checking src/_data/metadata.js ==="
cat src/_data/metadata.js

echo -e "\n=== Checking other templates' data source usage ==="
rg -n 'metadata\.(siteUrl|title|author)' --type=njk src/
rg -n 'site\.(siteUrl|title|author|description)' --type=njk src/

Repository: CourtneyFradreck/CourtNotes

Length of output: 578


🏁 Script executed:

#!/bin/bash
# Find all .njk template files mentioned in the review
echo "=== Locating .njk template files ==="
fd -e njk src/

echo -e "\n=== Content of llms.txt.njk ==="
cat src/llms.txt.njk

echo -e "\n=== Content of sitemap.xml.njk ==="
cat src/sitemap.xml.njk

echo -e "\n=== Content of robots.txt.njk ==="
cat src/robots.txt.njk

Repository: CourtneyFradreck/CourtNotes

Length of output: 2510


🏁 Script executed:

#!/bin/bash
# Check how siteMetadata is exported/registered in .eleventy.js
echo "=== Checking .eleventy.js for how siteMetadata is made available ==="
cat .eleventy.js

Repository: CourtneyFradreck/CourtNotes

Length of output: 1855


Standardize template data source usage.

This template uses site.* variables, but sitemap.xml.njk and robots.txt.njk use metadata.* instead. The sources diverge—metadata.js exports only title, author, and siteUrl, while .eleventy.js defines siteMetadata with an additional description field. This inconsistency creates maintenance risk and potential for bugs if the values diverge further.

Standardize on a single data source across all templates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/llms.txt.njk` around lines 5 - 7, Templates are using two different data
sources (site.* in src/llms.txt.njk vs metadata.* in sitemap.xml.njk and
robots.txt.njk); pick one and make all templates consistent—I suggest
standardizing on metadata.*: change src/llms.txt.njk to use metadata.title and
metadata.description instead of site.title/site.description, and update
metadata.js to export description (or sync .eleventy.js siteMetadata.description
into metadata.js) so metadata contains title, author, siteUrl, and description;
ensure any code referencing siteMetadata is updated to reference metadata where
applicable.


CourtNotes is a personal blog by {{ site.author }}, a Zimbabwe-based software engineering student, startup builder, and tech community lead. The site captures the journey of learning in public — from experiments in Eleventy to reflections on leadership, startup execution, and Africa-focused technology.

Courtney is the CEO and project lead at Proxyon Technologies, building AgriLease — a mobile-first marketplace helping farmers discover, book, and pay for agricultural equipment. He also serves as Tech Lead for GDG on Campus at his university.

## Pages

- [About]({{ site.siteUrl }}/about/): Get to know Courtney Fradreck — the person behind CourtNotes.
- [Blog]({{ site.siteUrl }}/blog/): All blog posts and articles.

## Blog Posts
{% for post in collections.post | reverse %}
- [{{ post.data.title }}]({{ site.siteUrl }}{{ post.url }}): {{ post.data.description }}
{%- endfor %}
1 change: 1 addition & 0 deletions src/robots.txt.njk
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ User-agent: *
Allow: /

Sitemap: {{ metadata.siteUrl }}/sitemap.xml
Llms-Txt: {{ metadata.siteUrl }}/llms.txt
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Llms-Txt: is not a standard robots.txt directive, so most crawlers will ignore it (and some validators may flag it). If the goal is discoverability, consider relying on the well-known /llms.txt path (and/or linking it from HTML) rather than adding a non-standard robots.txt field.

Suggested change
Llms-Txt: {{ metadata.siteUrl }}/llms.txt

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Is "Llms-Txt" a recognized directive in robots.txt for advertising llms.txt files?

💡 Result:

No, "Llms-Txt" is not a recognized directive in robots.txt for advertising llms.txt files. The robots.txt standard, as defined by robotstxt.org, the original 1994 specification, RFC 9309, and Google's implementation, only recognizes specific directives: User-agent, Disallow, Allow, and Sitemap. No source mentions "Llms-Txt" as a valid directive. llms.txt is a separate proposed standard (from llmstxt.org, 2024) for AI guidance, distinct from robots.txt. Some non-standard practices suggest adding Allow: /llms.txt or informal lines like "LLMS: https://example.com/llms.txt" in robots.txt to hint at the file, but these are not part of the official specification and crawlers are not required to recognize or act on them.

Citations:


Remove the non-standard "Llms-Txt" directive from robots.txt.

"Llms-Txt" is not a recognized directive in the robots.txt specification (RFC 9309). Standard robots.txt parsers only recognize User-agent, Disallow, Allow, and Sitemap. This directive will be ignored by all standard crawlers.

The llms.txt standard is a separate specification (from llmstxt.org, 2024) distinct from robots.txt. To advertise an llms.txt file, use the official Sitemap directive instead, or ensure the file is accessible at the standard path (/llms.txt) where AI crawlers can discover it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/robots.txt.njk` at line 9, Remove the non-standard "Llms-Txt: {{
metadata.siteUrl }}/llms.txt" directive from src/robots.txt.njk; either delete
that line or replace it with a standard directive such as "Sitemap: {{
metadata.siteUrl }}/llms.txt" so crawlers can discover the llms.txt via the
recognized Sitemap directive, and ensure the llms.txt file is served at the
standard path (/llms.txt).

10 changes: 9 additions & 1 deletion src/sitemap.xml.njk
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ eleventyExcludeFromCollections: true
{% if page.url and page.url != "/404.html" %}
<url>
<loc>{{ metadata.siteUrl | default("https://courtnotes.netlify.app") }}{{ page.url }}</loc>
{# Ensure the date is formatted as YYYY-MM-DD #}
<lastmod>{{ page.date.toISOString().split('T')[0] if page.date else "2024-01-01" }}</lastmod>
{% if page.url == "/about/" %}
<priority>1.0</priority>
{% elif page.url == "/" %}
<priority>0.9</priority>
{% elif page.url == "/blog/" %}
<priority>0.8</priority>
{% else %}
<priority>0.6</priority>
{% endif %}
</url>
{% endif %}
{% endfor %}
Expand Down