Integrate Pagefind search and taxonomy generation (#43)

* feat: generate monthly and yearly rollups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Integrate Pagefind search and taxonomy generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed May 18, 2026 at 16:07 UTC 35ac7f9f6ba03b9e6005938a791ae350722f56bd
24 files changed +1196 -48
.github/workflows/crawl-and-publish.yml
+11 -5
@@ -338,6 +338,9 @@ jobs:
338 print(f"page_path={page_path.as_posix()}")
339 PYGEN
340
341 + - name: Generate rollups
342 + run: python3 scripts/generate_rollups.py
343 +
344 - name: Commit generated content
345 env:
346 DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
@@ -345,18 +348,18 @@ jobs:
348 run: |
349 git config user.name "github-actions[bot]"
350 git config user.email "github-actions[bot]@users.noreply.github.com"
348 - if ! git status --short -- content/weekly | grep -q .; then
351 + if ! git status --short -- content/weekly content/monthly content/yearly | grep -q .; then
352 echo "No generated content changes to commit."
353 exit 0
354 fi
352 - git stash push --include-untracked --message generated-content -- content/weekly
355 + git stash push --include-untracked --message generated-content -- content/weekly content/monthly content/yearly
356 git fetch origin "$DEFAULT_BRANCH"
357 git checkout -B "$DEFAULT_BRANCH" "origin/$DEFAULT_BRANCH"
358 git stash pop || {
359 echo "Failed to reapply generated content after syncing $DEFAULT_BRANCH."
360 exit 1
361 }
359 - git add content/weekly/
362 + git add content/weekly/ content/monthly/ content/yearly/
363 git diff --cached --quiet || git commit -m "content: weekly page $WEEK"
364 git push origin "HEAD:$DEFAULT_BRANCH" || {
365 echo "Push failed after syncing with $DEFAULT_BRANCH."
@@ -367,7 +370,10 @@ jobs:
370 uses: actions/upload-artifact@v4
371 with:
372 name: generated-content
370 - path: content/weekly/
373 + path: |
374 + content/weekly/
375 + content/monthly/
376 + content/yearly/
377 if-no-files-found: warn
378
379 deploy:
@@ -407,7 +413,7 @@ jobs:
413 name: analyzed-data
414 path: data/analyzed/
415
410 - # The generate job already pushes weekly content to the default branch.
416 + # The generate job already pushes weekly, monthly, and yearly content to the default branch.
417 # Deploy builds from that branch state to avoid artifact extraction conflicts.
418 - name: Configure GitHub Pages
419 uses: actions/configure-pages@v5
.github/workflows/deploy-site.yml
+8
@@ -31,6 +31,11 @@ jobs:
31 - name: Configure GitHub Pages
32 uses: actions/configure-pages@v5
33
34 + - name: Set up Node.js
35 + uses: actions/setup-node@v4
36 + with:
37 + node-version: '20'
38 +
39 - name: Install Hugo
40 run: |
41 TARBALL="hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz"
@@ -51,6 +56,9 @@ jobs:
56 - name: Build site
57 run: hugo --minify
58
59 + - name: Build Pagefind index
60 + run: npx pagefind --site public/
61 +
62 - name: Upload Pages artifact
63 uses: actions/upload-pages-artifact@v3
64 with:
assets/css/extended/squadscope.css
+41
@@ -141,6 +141,47 @@
141 padding: 0.75rem;
142 }
143
144 +.search-shell,
145 +.taxonomy-grid {
146 + display: grid;
147 + gap: 1rem;
148 +}
149 +
150 +.search-intro-card,
151 +.taxonomy-card {
152 + border: 1px solid var(--border);
153 + border-radius: var(--radius);
154 + background: var(--entry);
155 +}
156 +
157 +.search-intro-card {
158 + padding: 1rem 1.25rem;
159 +}
160 +
161 +.taxonomy-grid {
162 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
163 +}
164 +
165 +.taxonomy-card h2 {
166 + margin-top: 0;
167 + margin-bottom: 0.5rem;
168 +}
169 +
170 +.pagefind-meta {
171 + display: none;
172 +}
173 +
174 +:root {
175 + --pagefind-ui-scale: 0.95;
176 + --pagefind-ui-primary: var(--primary);
177 + --pagefind-ui-text: var(--content);
178 + --pagefind-ui-background: var(--entry);
179 + --pagefind-ui-border: var(--border);
180 + --pagefind-ui-tag: var(--tertiary);
181 + --pagefind-ui-border-radius: var(--radius);
182 + --pagefind-ui-font: inherit;
183 +}
184 +
185 @media (max-width: 640px) {
186 .metric-card-wide {
187 grid-column: span 1;
content/categories/_index.md new
+8
@@ -0,0 +1,8 @@
1 ++++
2 +title = 'Categories'
3 +date = '2026-05-18T15:22:25+02:00'
4 +draft = false
5 +summary = 'Browse SquadScope report types across the site.'
6 ++++
7 +
8 +Categories group reports by cadence so you can move between weekly, monthly, and yearly views without losing context.
content/monthly/2026/05.md
+30 -14
@@ -1,17 +1,33 @@
1 -+++
2 -title = 'May 2026 Rollup'
3 -date = '2026-05-18'
4 -draft = false
5 -summary = 'Placeholder monthly rollup used to validate navigation and section rendering.'
6 -tags = ['ai', 'devtools', 'rust']
7 -categories = ['monthly']
8 -month = '2026-05'
9 -+++
1 +---
2 +title: "May 2026 Rollup"
3 +date: "2026-05-18T12:07:20.778000+02:00"
4 +month: 5
5 +year: 2026
6 +categories: ["monthly"]
7 +weeks_covered: ["2026-W21"]
8 +total_repos_featured: 424
9 +---
10 +## Month Overview
11
11 -This placeholder monthly page exists so the homepage and navigation can point to a valid month-level summary while the generator pipeline is still being built.
12 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
13 +- Summary: Week 21 shows real demand for agent infrastructure, but the trend data still lacks the baseline needed to separate momentum from popularity.
14 +- Repositories featured this week: 424
15 +- Recurring themes so far: ai, agents, developer-tooling.
16
13 -## Signals to capture
17 +## Top Repos This Month
18
15 -- Repeated themes across weekly reports
16 -- Category momentum worth watching
17 -- Standout repositories that kept showing up
19 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
20 +- [vercel-labs/zero](https://github.com/vercel-labs/zero) led the published weekly analysis for 2026-W21.
21 +- Detailed breakdown: [Week 21, 2026](/weekly/2026/W21/).
22 +
23 +## Trends Observed
24 +
25 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
26 +- Signal: The durable signal is the shift from general AI enthusiasm toward operational tooling. The top shared topics — **python**, **ai**, **llm**, **typescript**, **nodejs**, and **javascript** — reinforce that the center of gravity is still developer-facing AI, but the better projects are focused on workflow reliability, packaging, and execution discipline. This is a healthier pattern than pure demo-driven hype because it implies the ecosystem is starting to care about how agent systems are run, maintained, and trusted.
27 +- Noise: The weak signal is the amount of off-mission and exploit-heavy material that still clears the crawler. Security appears often, but too much of that volume is bypass, exploit, or cheat-oriented rather than defensive engineering. There is also obvious repetition in the agent category: many launches gesture at automation without much evidence of differentiation. That means the week is loud, but not all of that loudness deserves equal editorial weight.
28 +
29 +## Key Takeaways
30 +
31 +### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)
32 +- Gap to watch: The biggest missing piece is trustworthy momentum data. Without historical star snapshots, the analyzer cannot distinguish what is newly accelerating from what is simply already famous. The second gap is stronger quality filtering: exploit repositories, cheat tooling, and other off-mission projects still distort the weekly picture. The third is ecosystem balance. There is plenty of heat around AI builders, but much less visible energy around defensive security tooling, testing infrastructure for agents, and pragmatic maintenance tools that help teams run these systems safely at scale.
33 +- Closing read: Week 21 matters because it shows where the GitHub conversation is maturing: away from generic AI excitement and toward tooling that makes agent workflows usable. But it also shows why the analyzer contract has to be strict. Until the pipeline has real trend baselines and better filtering, the right editorial stance is confident about the signal, skeptical about the noise, and explicit about the gaps.
content/search.md deleted
-9
@@ -1,9 +0,0 @@
1 -+++
2 -title = 'Search'
3 -date = '2026-05-18T10:27:35.339+02:00'
4 -draft = false
5 -layout = 'search'
6 -summary = 'Search weekly, monthly, and yearly SquadScope reports.'
7 -+++
8 -
9 -Search across the entire archive by topic, category, or keyword.
content/search/_index.md new
+12
@@ -0,0 +1,12 @@
1 ++++
2 +title = 'Search'
3 +date = '2026-05-18T10:27:35.339+02:00'
4 +draft = false
5 +layout = 'pagefind-search'
6 +pagefind = true
7 +summary = 'Search weekly, monthly, and yearly SquadScope reports with Pagefind filters.'
8 ++++
9 +
10 +Search across the entire archive by topic, category, tag, year, or repository name.
11 +
12 +Pagefind runs entirely at build time, so this page stays static while still offering instant, client-side results.
content/tags/_index.md new
+8
@@ -0,0 +1,8 @@
1 ++++
2 +title = 'Tags'
3 +date = '2026-05-18T15:22:25+02:00'
4 +draft = false
5 +summary = 'Browse SquadScope topics across weekly, monthly, and yearly reports.'
6 ++++
7 +
8 +Use tags to jump straight into recurring themes such as AI, security, platform engineering, and developer tooling.
content/yearly/2026.md
+35 -17
@@ -1,17 +1,35 @@
1 -+++
2 -title = '2026 Year in Review'
3 -date = '2026-05-18'
4 -draft = false
5 -summary = 'Placeholder yearly summary used to validate the long-view SquadScope structure.'
6 -tags = ['platform', 'security', 'tooling']
7 -categories = ['yearly']
8 -year = '2026'
9 -+++
10 -
11 -This placeholder yearly page confirms that Hugo can route a long-view trend summary alongside the weekly and monthly content hierarchy.
12 -
13 -## Themes to watch
14 -
15 -- Platform engineering maturity
16 -- Security defaults becoming product features
17 -- Tooling that collapses operational complexity
1 +---
2 +title: "2026 Yearly Rollup"
3 +date: "2026-05-18T12:07:20.778000+02:00"
4 +year: 2026
5 +categories: ["yearly"]
6 +months_covered: ["2026-05"]
7 +---
8 +## Year in Review
9 +
10 +### May 2026 update — 2026-W21
11 +- [May 2026](/monthly/2026/05/) gained a new weekly signal via [Week 21, 2026](/weekly/2026/W21/).
12 +- Snapshot: Week 21 shows real demand for agent infrastructure, but the trend data still lacks the baseline needed to separate momentum from popularity.
13 +
14 +## Biggest Trends
15 +
16 +### May 2026 update — 2026-W21
17 +- Themes in rotation: ai, agents, developer-tooling, security, open-source.
18 +- Signal from [Week 21, 2026](/weekly/2026/W21/): The durable signal is the shift from general AI enthusiasm toward operational tooling. The top shared topics — **python**, **ai**, **llm**, **typescript**, **nodejs**, and **javascript** — reinforce that the center of gravity is still developer-facing AI, but the better projects are focused on workflow reliability, packaging, and execution discipline. This is a healthier pattern than pure demo-driven hype because it implies the ecosystem is starting to care about how agent systems are run, maintained, and trusted.
19 +
20 +## Most Impactful Repos
21 +
22 +### May 2026 update — 2026-W21
23 +- Featured repo: [vercel-labs/zero](https://github.com/vercel-labs/zero).
24 +- Month summary: [May 2026](/monthly/2026/05/).
25 +
26 +## What Changed
27 +
28 +### May 2026 update — 2026-W21
29 +- Friction noted in [Week 21, 2026](/weekly/2026/W21/): The weak signal is the amount of off-mission and exploit-heavy material that still clears the crawler. Security appears often, but too much of that volume is bypass, exploit, or cheat-oriented rather than defensive engineering. There is also obvious repetition in the agent category: many launches gesture at automation without much evidence of differentiation. That means the week is loud, but not all of that loudness deserves equal editorial weight.
30 +
31 +## Predictions Review
32 +
33 +### May 2026 update — 2026-W21
34 +- Open question carried forward from [May 2026](/monthly/2026/05/): The biggest missing piece is trustworthy momentum data. Without historical star snapshots, the analyzer cannot distinguish what is newly accelerating from what is simply already famous. The second gap is stronger quality filtering: exploit repositories, cheat tooling, and other off-mission projects still distort the weekly picture. The third is ecosystem balance. There is plenty of heat around AI builders, but much less visible energy around defensive security tooling, testing infrastructure for agents, and pragmatic maintenance tools that help teams run these systems safely at scale.
35 +- Working takeaway: Week 21 matters because it shows where the GitHub conversation is maturing: away from generic AI excitement and toward tooling that makes agent workflows usable. But it also shows why the analyzer contract has to be strict. Until the pipeline has real trend baselines and better filtering, the right editorial stance is confident about the signal, skeptical about the noise, and explicit about the gaps.
hugo.toml
+14 -2
@@ -67,17 +67,29 @@ rssLimit = 20
67 url = '/archive/'
68 weight = 40
69
70 + [[menu.main]]
71 + identifier = 'tags'
72 + name = 'Tags'
73 + url = '/tags/'
74 + weight = 50
75 +
76 + [[menu.main]]
77 + identifier = 'categories'
78 + name = 'Categories'
79 + url = '/categories/'
80 + weight = 60
81 +
82 [[menu.main]]
83 identifier = 'search'
84 name = 'Search'
85 url = '/search/'
74 - weight = 50
86 + weight = 70
87
88 [[menu.main]]
89 identifier = 'rss'
90 name = 'RSS'
91 url = '/index.xml'
80 - weight = 60
92 + weight = 80
93
94 [markup]
95 [markup.goldmark.renderer]
layouts/_default/single.html new
+61
@@ -0,0 +1,61 @@
1 +{{- define "main" }}
2 +<article class="post-single squad-report" data-pagefind-body>
3 + {{ partial "pagefind-metadata.html" . }}
4 +
5 + <header class="post-header">
6 + {{ partial "breadcrumbs.html" . }}
7 + <p class="section-topline">{{ .Type | humanize }}</p>
8 + <h1 class="post-title entry-hint-parent">{{ .Title }}</h1>
9 + {{- with (.Description | default .Params.summary) }}
10 + <div class="post-description">{{ . }}</div>
11 + {{- end }}
12 + {{- if not (.Param "hideMeta") }}
13 + <div class="post-meta">
14 + {{- partial "post_meta.html" . -}}
15 + {{- partial "translation_list.html" . -}}
16 + {{- partial "edit_post.html" . -}}
17 + {{- partial "post_canonical.html" . -}}
18 + </div>
19 + {{- end }}
20 + </header>
21 +
22 + <nav class="report-shortcuts" aria-label="Report navigation">
23 + <a href="{{ `/search/` | relURL }}">Search</a>
24 + <a href="{{ `/tags/` | relURL }}">Tags</a>
25 + <a href="{{ `/categories/` | relURL }}">Categories</a>
26 + <a href="{{ `/archive/` | relURL }}">Archive</a>
27 + <a href="{{ `/index.xml` | relURL }}">RSS</a>
28 + </nav>
29 +
30 + {{- if .Content }}
31 + <div class="post-content md-content">
32 + {{- if not (.Param "disableAnchoredHeadings") }}
33 + {{- partial "anchored_headings.html" .Content -}}
34 + {{- else }}{{ .Content }}{{ end }}
35 + </div>
36 + {{- end }}
37 +
38 + <footer class="post-footer">
39 + {{- $tags := .Language.Params.Taxonomies.tag | default "tags" }}
40 + {{- $categories := .Language.Params.Taxonomies.category | default "categories" }}
41 + <ul class="post-tags">
42 + {{- range ($.GetTerms $categories) }}
43 + <li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
44 + {{- end }}
45 + {{- range ($.GetTerms $tags) }}
46 + <li><a href="{{ .Permalink }}">{{ .LinkTitle }}</a></li>
47 + {{- end }}
48 + </ul>
49 + {{- if (.Param "ShowPostNavLinks") }}
50 + {{- partial "post_nav_links.html" . }}
51 + {{- end }}
52 + {{- if (and site.Params.ShowShareButtons (ne .Params.disableShare true)) }}
53 + {{- partial "share_icons.html" . -}}
54 + {{- end }}
55 + </footer>
56 +
57 + {{- if (.Param "comments") }}
58 + {{- partial "comments.html" . }}
59 + {{- end }}
60 +</article>
61 +{{- end }}
layouts/_default/taxonomy.html new
+26
@@ -0,0 +1,26 @@
1 +{{- define "main" -}}
2 +<header class="page-header">
3 + {{- partial "breadcrumbs.html" . }}
4 + <h1>{{ .Title }}</h1>
5 + <div class="post-description">All {{ .Pages.Len }} page{{ if ne .Pages.Len 1 }}s{{ end }} filed under {{ .Data.Plural | humanize | lower }}.</div>
6 +</header>
7 +
8 +{{- if .Content }}
9 +<div class="post-content md-content">
10 + {{ .Content }}
11 +</div>
12 +{{- end }}
13 +
14 +<section class="taxonomy-grid" aria-label="{{ .Title }} pages">
15 + {{- range .Pages.ByDate.Reverse }}
16 + <article class="home-report-card taxonomy-card">
17 + <h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
18 + <p>{{ .Params.summary | default .Summary }}</p>
19 + <div class="home-report-meta">
20 + <span>{{ .Date.Format "2006-01-02" }}</span>
21 + <span>{{ .Type | humanize }}</span>
22 + </div>
23 + </article>
24 + {{- end }}
25 +</section>
26 +{{- end -}}
layouts/_default/terms.html new
+33
@@ -0,0 +1,33 @@
1 +{{- define "main" -}}
2 +<header class="page-header">
3 + {{- partial "breadcrumbs.html" . }}
4 + <h1>{{ .Title }}</h1>
5 + {{- with (.Description | default .Params.summary) }}
6 + <div class="post-description">{{ . }}</div>
7 + {{- end }}
8 +</header>
9 +
10 +{{- if .Content }}
11 +<div class="post-content md-content">
12 + {{ .Content }}
13 +</div>
14 +{{- end }}
15 +
16 +<section class="taxonomy-grid" aria-label="{{ .Title }} terms">
17 + {{- $type := .Type -}}
18 + {{- range .Data.Terms.Alphabetical }}
19 + {{- $term := .Name -}}
20 + {{- $count := .Count -}}
21 + {{- with site.GetPage (printf "/%s/%s" $type $term) }}
22 + <article class="home-report-card taxonomy-card">
23 + <h2><a href="{{ .RelPermalink }}">{{ .LinkTitle }}</a></h2>
24 + <p>{{ .Params.summary | default (printf "Browse %d related page%s." $count (cond (eq $count 1) "" "s")) }}</p>
25 + <div class="home-report-meta">
26 + <span>{{ $count }} page{{ if ne $count 1 }}s{{ end }}</span>
27 + <span>{{ $type | humanize }}</span>
28 + </div>
29 + </article>
30 + {{- end }}
31 + {{- end }}
32 +</section>
33 +{{- end -}}
layouts/index.html
+12
@@ -26,6 +26,18 @@
26 <span class="home-nav-title">Yearly</span>
27 <span class="home-nav-copy">Follow broader platform shifts and long-running trend lines.</span>
28 </a>
29 + <a class="home-nav-card" href="{{ "/tags/" | relURL }}">
30 + <span class="home-nav-title">Tags</span>
31 + <span class="home-nav-copy">Jump into recurring topics like AI, security, and developer tooling.</span>
32 + </a>
33 + <a class="home-nav-card" href="{{ "/categories/" | relURL }}">
34 + <span class="home-nav-title">Categories</span>
35 + <span class="home-nav-copy">Move between weekly, monthly, and yearly reporting cadences.</span>
36 + </a>
37 + <a class="home-nav-card" href="{{ "/search/" | relURL }}">
38 + <span class="home-nav-title">Search</span>
39 + <span class="home-nav-copy">Use Pagefind to search by keyword, filter, date, or repository name.</span>
40 + </a>
41 <a class="home-nav-card" href="{{ "/index.xml" | relURL }}">
42 <span class="home-nav-title">RSS</span>
43 <span class="home-nav-copy">Subscribe to the newest reports in your feed reader.</span>
layouts/partials/extend_head.html new
+4
@@ -0,0 +1,4 @@
1 +{{- if .Params.pagefind -}}
2 +<link rel="stylesheet" href="{{ `/pagefind/pagefind-ui.css` | relURL }}">
3 +<script src="{{ `/pagefind/pagefind-ui.js` | relURL }}" defer></script>
4 +{{- end -}}
layouts/partials/head.html new
+194
@@ -0,0 +1,194 @@
1 +<meta charset="utf-8">
2 +<meta http-equiv="X-UA-Compatible" content="IE=edge">
3 +<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
4 +{{- if hugo.IsProduction | or (eq site.Params.env "production") | and (ne .Params.robotsNoIndex true) }}
5 +<meta name="robots" content="index, follow">
6 +{{- else }}
7 +<meta name="robots" content="noindex, nofollow">
8 +{{- end }}
9 +
10 +{{- /* Title */}}
11 +<title>{{ if .IsHome }}{{ else }}{{ if .Title }}{{ .Title }} | {{ end }}{{ end }}{{ site.Title }}</title>
12 +
13 +{{- /* Meta */}}
14 +{{- if .IsHome }}
15 +{{ with site.Params.keywords -}}<meta name="keywords" content="{{- range $i, $e := . }}{{ if $i }}, {{ end }}{{ $e }}{{ end }}">{{ end }}
16 +{{- else }}
17 +<meta name="keywords" content="{{ if .Params.keywords -}}
18 + {{- range $i, $e := .Params.keywords }}{{ if $i }}, {{ end }}{{ $e }}{{ end }} {{- else }}
19 + {{- range $i, $e := .Params.tags }}{{ if $i }}, {{ end }}{{ $e }}{{ end }} {{- end -}}">
20 +{{- end }}
21 +<meta name="description" content="{{- with .Description }}{{ . }}{{- else }}{{- if or .IsPage .IsSection}}
22 + {{- .Summary | default (printf "%s - %s" .Title site.Title) }}{{- else }}
23 + {{- with site.Params.description }}{{ . }}{{- end }}{{- end }}{{- end -}}">
24 +<meta name="author" content="{{ (partial "author.html" . ) }}">
25 +<link rel="canonical" href="{{ if .Params.canonicalURL -}} {{ trim .Params.canonicalURL " " }} {{- else -}} {{ .Permalink }} {{- end }}">
26 +{{- if site.Params.analytics.google.SiteVerificationTag }}
27 +<meta name="google-site-verification" content="{{ site.Params.analytics.google.SiteVerificationTag }}">
28 +{{- end }}
29 +{{- if site.Params.analytics.yandex.SiteVerificationTag }}
30 +<meta name="yandex-verification" content="{{ site.Params.analytics.yandex.SiteVerificationTag }}">
31 +{{- end }}
32 +{{- if site.Params.analytics.bing.SiteVerificationTag }}
33 +<meta name="msvalidate.01" content="{{ site.Params.analytics.bing.SiteVerificationTag }}">
34 +{{- end }}
35 +{{- if site.Params.analytics.naver.SiteVerificationTag }}
36 +<meta name="naver-site-verification" content="{{ site.Params.analytics.naver.SiteVerificationTag }}">
37 +{{- end }}
38 +
39 +{{- /* Styles */}}
40 +
41 +{{- /* includes */}}
42 +{{- $includes := slice }}
43 +{{- $includes = $includes | append (" " | resources.FromString "assets/css/includes-blank.css")}}
44 +
45 +{{- $includes_all := $includes | resources.Concat "assets/css/includes.css" }}
46 +
47 +{{- $theme_vars := (resources.Get "css/core/theme-vars.css") }}
48 +{{- $reset := (resources.Get "css/core/reset.css") }}
49 +{{- $media := (resources.Get "css/core/zmedia.css") }}
50 +{{- $license_css := (resources.Get "css/core/license.css") }}
51 +{{- $common := (resources.Match "css/common/*.css") | resources.Concat "assets/css/common.css" }}
52 +
53 +{{- /* markup.highlight.noClasses should be set to `false` */}}
54 +{{- $chroma_styles := (resources.Get "css/includes/chroma-styles.css") }}
55 +{{- $chroma_mod := (resources.Get "css/includes/chroma-mod.css") }}
56 +
57 +{{- /* order is important */}}
58 +{{- $core := (slice $theme_vars $reset $common $chroma_styles $chroma_mod $includes_all $media) | resources.Concat "assets/css/core.css" | resources.Minify }}
59 +{{- $extended := (resources.Match "css/extended/*.css") | resources.Concat "assets/css/extended.css" | resources.Minify }}
60 +
61 +{{- /* bundle all required css */}}
62 +{{- /* Add extended css after theme style */ -}}
63 +{{- $stylesheet := (slice $license_css $core $extended) | resources.Concat "assets/css/stylesheet.css" }}
64 +
65 +{{- if not site.Params.assets.disableFingerprinting }}
66 +{{- $stylesheet := $stylesheet | fingerprint }}
67 +<link crossorigin="anonymous" href="{{ $stylesheet.RelPermalink }}" integrity="{{ $stylesheet.Data.Integrity }}" rel="preload stylesheet" as="style">
68 +{{- else }}
69 +<link crossorigin="anonymous" href="{{ $stylesheet.RelPermalink }}" rel="preload stylesheet" as="style">
70 +{{- end }}
71 +
72 +{{- /* Search */}}
73 +{{- if (and (eq .Layout `search`) (not .Params.pagefind)) -}}
74 +<link crossorigin="anonymous" rel="preload" as="fetch" href="../index.json">
75 +{{- $fastsearch := resources.Get "js/fastsearch.js" | js.Build (dict "params" (dict "fuseOpts" site.Params.fuseOpts)) | resources.Minify }}
76 +{{- $fusejs := resources.Get "js/fuse.basic.min.js" }}
77 +{{- $license_js := resources.Get "js/license.js" }}
78 +{{- if not site.Params.assets.disableFingerprinting }}
79 +{{- $search := (slice $fusejs $license_js $fastsearch ) | resources.Concat "assets/js/search.js" | fingerprint }}
80 +<script defer crossorigin="anonymous" src="{{ $search.RelPermalink }}" integrity="{{ $search.Data.Integrity }}"></script>
81 +{{- else }}
82 +{{- $search := (slice $fusejs $fastsearch ) | resources.Concat "assets/js/search.js" }}
83 +<script defer crossorigin="anonymous" src="{{ $search.RelPermalink }}"></script>
84 +{{- end }}
85 +{{- end -}}
86 +
87 +{{- /* Favicons */}}
88 +<link rel="icon" href="{{ site.Params.assets.favicon | default "favicon.ico" | absURL }}">
89 +<link rel="icon" type="image/png" sizes="16x16" href="{{ site.Params.assets.favicon16x16 | default "favicon-16x16.png" | absURL }}">
90 +<link rel="icon" type="image/png" sizes="32x32" href="{{ site.Params.assets.favicon32x32 | default "favicon-32x32.png" | absURL }}">
91 +<link rel="apple-touch-icon" href="{{ site.Params.assets.apple_touch_icon | default "apple-touch-icon.png" | absURL }}">
92 +<link rel="mask-icon" href="{{ site.Params.assets.safari_pinned_tab | default "safari-pinned-tab.svg" | absURL }}">
93 +<meta name="theme-color" content="{{ site.Params.assets.theme_color | default "#2e2e33" }}">
94 +<meta name="msapplication-TileColor" content="{{ site.Params.assets.msapplication_TileColor | default "#2e2e33" }}">
95 +
96 +{{- /* RSS */}}
97 +{{ range .AlternativeOutputFormats -}}
98 +<link rel="{{ .Rel }}" type="{{ .MediaType.Type | html }}" href="{{ .Permalink | safeURL }}" title="{{ .Name }}">
99 +{{ end -}}
100 +{{- range .AllTranslations -}}
101 +<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">
102 +{{ end -}}
103 +
104 +<noscript>
105 + <style>
106 + #theme-toggle,
107 + .top-link {
108 + display: none;
109 + }
110 +
111 + </style>
112 + {{- if (and (ne site.Params.defaultTheme "light") (ne site.Params.defaultTheme "dark")) }}
113 + <style>
114 + @media (prefers-color-scheme: dark) {
115 + :root {
116 + --theme: rgb(29, 30, 32);
117 + --entry: rgb(46, 46, 51);
118 + --primary: rgb(218, 218, 219);
119 + --secondary: rgb(155, 156, 157);
120 + --tertiary: rgb(65, 66, 68);
121 + --content: rgb(196, 196, 197);
122 + --code-block-bg: rgb(46, 46, 51);
123 + --code-bg: rgb(55, 56, 62);
124 + --border: rgb(51, 51, 51);
125 + color-scheme: dark;
126 + }
127 +
128 + .list {
129 + background: var(--theme);
130 + }
131 +
132 + .toc {
133 + background: var(--entry);
134 + }
135 + }
136 +
137 + </style>
138 + {{- end }}
139 +</noscript>
140 +
141 +{{- /* theme-toggle is enabled */}}
142 +{{- if (not site.Params.disableThemeToggle) }}
143 +{{- /* theme is light */}}
144 +{{- if (eq site.Params.defaultTheme "light") }}
145 +<script>
146 + if (localStorage.getItem("pref-theme") === "dark") {
147 + document.querySelector("html").dataset.theme = 'dark';
148 + }
149 +
150 +</script>
151 +{{- /* theme is dark */}}
152 +{{- else if (eq site.Params.defaultTheme "dark") }}
153 +<script>
154 + if (localStorage.getItem("pref-theme") === "light") {
155 + document.querySelector("html").dataset.theme = 'light';
156 + }
157 +
158 +</script>
159 +{{- else }}
160 +{{- /* theme is auto */}}
161 +<script>
162 + if (localStorage.getItem("pref-theme") === "dark") {
163 + document.querySelector("html").dataset.theme = 'dark';
164 + } else if (localStorage.getItem("pref-theme") === "light") {
165 + document.querySelector("html").dataset.theme = 'light';
166 + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
167 + document.querySelector("html").dataset.theme = 'dark';
168 + } else {
169 + document.querySelector("html").dataset.theme = 'light';
170 + }
171 +
172 +</script>
173 +{{- end }}
174 +{{- /* theme-toggle is disabled and theme is auto */}}
175 +{{- else if (and (ne site.Params.defaultTheme "light") (ne site.Params.defaultTheme "dark"))}}
176 +<script>
177 + if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
178 + document.querySelector("html").dataset.theme = 'dark';
179 + } else {
180 + document.querySelector("html").dataset.theme = 'light';
181 + }
182 +
183 +</script>
184 +{{- end }}
185 +
186 +{{- partial "extend_head.html" . -}}
187 +
188 +{{- /* Misc */}}
189 +{{- if hugo.IsProduction | or (eq site.Params.env "production") }}
190 +{{- partial "google_analytics.html" . }}
191 +{{- partial "templates/opengraph.html" . }}
192 +{{- partial "templates/twitter_cards.html" . }}
193 +{{- partial "templates/schema_json.html" . }}
194 +{{- end -}}
layouts/partials/pagefind-metadata.html new
+26
@@ -0,0 +1,26 @@
1 +{{- $tags := .Params.tags | default (slice) -}}
2 +{{- $categories := .Params.categories | default (slice) -}}
3 +{{- $repoFilters := slice -}}
4 +{{- with .Params.top_repo -}}
5 + {{- $repoFilters = $repoFilters | append . -}}
6 +{{- end -}}
7 +{{- range findRE `https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+` .RawContent -}}
8 + {{- $repoFilters = $repoFilters | append (replaceRE `^https://github\.com/` `` .) -}}
9 +{{- end -}}
10 +{{- $repoFilters = uniq $repoFilters -}}
11 +<div class="pagefind-meta" data-pagefind-ignore>
12 + <span data-pagefind-meta="date:{{ .Date.Format `2006-01-02` }}"></span>
13 + <span data-pagefind-meta="section:{{ .Type }}"></span>
14 + <span data-pagefind-sort="date">{{ .Date.Format `2006-01-02` }}</span>
15 + <span data-pagefind-filter="Date">{{ .Date.Format `2006-01-02` }}</span>
16 + <span data-pagefind-filter="Year">{{ .Date.Format `2006` }}</span>
17 + {{- range $categories }}
18 + <span data-pagefind-filter="Categories">{{ . }}</span>
19 + {{- end }}
20 + {{- range $tags }}
21 + <span data-pagefind-filter="Tags">{{ . }}</span>
22 + {{- end }}
23 + {{- range $repoFilters }}
24 + <span data-pagefind-filter="Repository">{{ . }}</span>
25 + {{- end }}
26 +</div>
layouts/search/list.html new
+44
@@ -0,0 +1,44 @@
1 +{{- define "main" -}}
2 +<header class="page-header">
3 + {{- partial "breadcrumbs.html" . }}
4 + <h1>{{ .Title }}</h1>
5 + {{- with (.Description | default .Params.summary) }}
6 + <div class="post-description">{{ . }}</div>
7 + {{- end }}
8 +</header>
9 +
10 +{{- if .Content }}
11 +<div class="post-content md-content">
12 + {{ .Content }}
13 +</div>
14 +{{- end }}
15 +
16 +<section class="search-shell" aria-labelledby="search-interface-title">
17 + <div class="search-intro-card">
18 + <span class="section-topline">Static search</span>
19 + <h2 id="search-interface-title">Find trends by keyword or filter</h2>
20 + <p>Search every published report, then narrow the results by category, tag, year, or featured repository.</p>
21 + </div>
22 + <div id="search"></div>
23 +</section>
24 +
25 +<script>
26 + window.addEventListener("DOMContentLoaded", function () {
27 + if (!window.PagefindUI) return;
28 +
29 + new PagefindUI({
30 + element: "#search",
31 + bundlePath: "{{ `/pagefind/` | relURL }}",
32 + showSubResults: true,
33 + showImages: false,
34 + excerptLength: 24,
35 + focusOnSlash: true,
36 + sort: { date: "desc" },
37 + openFilters: ["Categories", "Tags", "Repository", "Year"],
38 + translations: {
39 + placeholder: "Search reports, tags, categories, or repos"
40 + }
41 + });
42 + });
43 +</script>
44 +{{- end -}}
layouts/weekly/single.html
+6 -1
@@ -1,5 +1,7 @@
1 {{- define "main" }}
2 -<article class="post-single squad-report">
2 +<article class="post-single squad-report" data-pagefind-body>
3 + {{ partial "pagefind-metadata.html" . }}
4 +
5 <header class="post-header">
6 {{ partial "breadcrumbs.html" . }}
7 <p class="section-topline">Weekly summary</p>
@@ -21,6 +23,9 @@
23 <a href="{{ "/archive/" | relURL }}">Archive</a>
24 <a href="{{ "/monthly/" | relURL }}">Monthly</a>
25 <a href="{{ "/yearly/" | relURL }}">Yearly</a>
26 + <a href="{{ "/tags/" | relURL }}">Tags</a>
27 + <a href="{{ "/categories/" | relURL }}">Categories</a>
28 + <a href="{{ "/search/" | relURL }}">Search</a>
29 <a href="{{ "/index.xml" | relURL }}">RSS</a>
30 </nav>
31
pytest.ini new
+2
@@ -0,0 +1,2 @@
1 +[pytest]
2 +pythonpath = .
scripts/__init__.py
scripts/generate_rollups.py new
+430
@@ -0,0 +1,430 @@
1 +#!/usr/bin/env python3
2 +from __future__ import annotations
3 +
4 +import argparse
5 +import re
6 +import sys
7 +from collections import Counter, defaultdict
8 +from dataclasses import dataclass
9 +from pathlib import Path
10 +from typing import Any
11 +
12 +if __package__ in {None, ""}:
13 + sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
14 +
15 +import scripts.analysis_gate as analysis_gate
16 +
17 +SUMMARY_SUFFIX = "-summary.md"
18 +WEEK_PATTERN = re.compile(r"^(?P<year>\d{4})-W(?P<week>\d{2})$")
19 +MONTHLY_SECTIONS = [
20 + "Month Overview",
21 + "Top Repos This Month",
22 + "Trends Observed",
23 + "Key Takeaways",
24 +]
25 +YEARLY_SECTIONS = [
26 + "Year in Review",
27 + "Biggest Trends",
28 + "Most Impactful Repos",
29 + "What Changed",
30 + "Predictions Review",
31 +]
32 +MONTH_NAMES = {
33 + 1: "January",
34 + 2: "February",
35 + 3: "March",
36 + 4: "April",
37 + 5: "May",
38 + 6: "June",
39 + 7: "July",
40 + 8: "August",
41 + 9: "September",
42 + 10: "October",
43 + 11: "November",
44 + 12: "December",
45 +}
46 +SECTION_HEADING_PATTERN = re.compile(r"(?m)^##\s+(.+?)\s*$")
47 +SUBSECTION_HEADING_TEMPLATE = r"(?ms)^###\s+{title}\s*$\n(.*?)(?=^###\s+|\Z)"
48 +
49 +
50 +class RollupError(ValueError):
51 + pass
52 +
53 +
54 +@dataclass(frozen=True)
55 +class WeeklySummary:
56 + source_path: Path
57 + title: str
58 + date: object
59 + week: str
60 + year: int
61 + month: int
62 + tags: tuple[str, ...]
63 + repos_featured: int
64 + top_repo: str
65 + summary: str
66 + signal: str
67 + noise: str
68 + gaps: str
69 + conclusion: str
70 +
71 + @property
72 + def week_number(self) -> int:
73 + match = WEEK_PATTERN.fullmatch(self.week)
74 + if not match:
75 + raise RollupError(f"Invalid week slug: {self.week}")
76 + return int(match.group("week"))
77 +
78 + @property
79 + def week_title(self) -> str:
80 + return f"Week {self.week_number}, {self.year}"
81 +
82 + @property
83 + def week_link(self) -> str:
84 + return f"/weekly/{self.year}/W{self.week_number:02d}/"
85 +
86 + @property
87 + def month_slug(self) -> str:
88 + return f"{self.year}-{self.month:02d}"
89 +
90 + @property
91 + def month_title(self) -> str:
92 + return f"{MONTH_NAMES[self.month]} {self.year}"
93 +
94 + @property
95 + def month_link(self) -> str:
96 + return f"/monthly/{self.year}/{self.month:02d}/"
97 +
98 +
99 +@dataclass(frozen=True)
100 +class RollupEntry:
101 + marker: str
102 + text: str
103 +
104 +
105 +@dataclass(frozen=True)
106 +class RollupPage:
107 + path: Path
108 + frontmatter: dict[str, Any]
109 + sections: dict[str, list[RollupEntry]]
110 + section_order: list[str]
111 +
112 +
113 +def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
114 + parser = argparse.ArgumentParser(description="Generate append-only monthly and yearly rollups from weekly analyses.")
115 + parser.add_argument("--analyzed-dir", type=Path, default=Path("data/analyzed"), help="Directory containing weekly summary markdown files.")
116 + parser.add_argument("--content-root", type=Path, default=Path("content"), help="Root content directory for generated rollups.")
117 + return parser.parse_args(argv)
118 +
119 +
120 +def yaml_quote(value: str) -> str:
121 + return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
122 +
123 +
124 +def yaml_value(value: Any) -> str:
125 + if isinstance(value, str):
126 + return yaml_quote(value)
127 + if isinstance(value, bool):
128 + return "true" if value else "false"
129 + if isinstance(value, int):
130 + return str(value)
131 + if isinstance(value, list):
132 + rendered = ", ".join(yaml_value(item) for item in value)
133 + return f"[{rendered}]"
134 + return str(value)
135 +
136 +
137 +def render_frontmatter(frontmatter: dict[str, Any]) -> str:
138 + lines = ["---"]
139 + for key, value in frontmatter.items():
140 + lines.append(f"{key}: {yaml_value(value)}")
141 + lines.extend(["---", ""])
142 + return "\n".join(lines)
143 +
144 +
145 +def split_sections(body: str) -> tuple[str, dict[str, str]]:
146 + matches = list(SECTION_HEADING_PATTERN.finditer(body))
147 + if not matches:
148 + return body.rstrip(), {}
149 +
150 + intro = body[: matches[0].start()].rstrip()
151 + sections: dict[str, str] = {}
152 + for index, match in enumerate(matches):
153 + start = match.end()
154 + end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
155 + sections[match.group(1).strip()] = body[start:end].strip("\n")
156 + return intro, sections
157 +
158 +
159 +def get_section_text(body: str, heading: str) -> str:
160 + matches = list(SECTION_HEADING_PATTERN.finditer(body))
161 + for index, match in enumerate(matches):
162 + if match.group(1).strip() != heading:
163 + continue
164 + start = match.end()
165 + end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
166 + return body[start:end].strip()
167 + return ""
168 +
169 +
170 +def get_subsection_text(section_body: str, heading: str) -> str:
171 + pattern = re.compile(SUBSECTION_HEADING_TEMPLATE.format(title=re.escape(heading)))
172 + match = pattern.search(section_body)
173 + return re.sub(r"\s+", " ", match.group(1).strip()) if match else ""
174 +
175 +
176 +def normalize_text(value: str) -> str:
177 + return re.sub(r"\s+", " ", value.strip())
178 +
179 +
180 +def repo_markdown(repo: str) -> str:
181 + return f"[{repo}](https://github.com/{repo})"
182 +
183 +
184 +def load_summary(path: Path) -> WeeklySummary:
185 + frontmatter, body = analysis_gate.extract_frontmatter(path.read_text(encoding="utf-8"))
186 + week = str(frontmatter.get("week", ""))
187 + match = WEEK_PATTERN.fullmatch(week)
188 + if not match:
189 + raise RollupError(f"Invalid weekly summary filename: {path.name}")
190 +
191 + date = analysis_gate.parse_datetime(frontmatter["date"])
192 + month = date.month
193 + trend_analysis = get_section_text(body, "Trend Analysis")
194 + signal = get_subsection_text(trend_analysis, "Signal")
195 + noise = get_subsection_text(trend_analysis, "Noise")
196 + gaps = get_subsection_text(get_section_text(body, "What's Missing"), "Gaps")
197 + conclusion = normalize_text(get_section_text(body, "Conclusion"))
198 +
199 + return WeeklySummary(
200 + source_path=path,
201 + title=str(frontmatter["title"]),
202 + date=date,
203 + week=week,
204 + year=int(frontmatter["year"]),
205 + month=month,
206 + tags=tuple(str(tag) for tag in frontmatter.get("tags", [])),
207 + repos_featured=int(frontmatter["repos_featured"]),
208 + top_repo=str(frontmatter["top_repo"]),
209 + summary=normalize_text(str(frontmatter["summary"])),
210 + signal=signal,
211 + noise=noise,
212 + gaps=gaps,
213 + conclusion=conclusion,
214 + )
215 +
216 +
217 +def load_weekly_summaries(analyzed_dir: Path) -> list[WeeklySummary]:
218 + summaries = [load_summary(path) for path in sorted(analyzed_dir.glob(f"*{SUMMARY_SUFFIX}"))]
219 + if not summaries:
220 + raise RollupError(f"No weekly summaries found in {analyzed_dir}")
221 + return sorted(summaries, key=lambda item: (item.date, item.week))
222 +
223 +
224 +def monthly_entries(weekly: WeeklySummary, tags_counter: Counter[str]) -> dict[str, RollupEntry]:
225 + common_tags = ", ".join(tag for tag, _ in tags_counter.most_common(3)) or "none yet"
226 + repo_link = repo_markdown(weekly.top_repo)
227 + page_link = f"[{weekly.week_title}]({weekly.week_link})"
228 + marker = f"### Week {weekly.week}"
229 + return {
230 + "Month Overview": RollupEntry(
231 + marker=marker,
232 + text=(
233 + f"{marker} — {page_link}\n"
234 + f"- Summary: {weekly.summary}\n"
235 + f"- Repositories featured this week: {weekly.repos_featured}\n"
236 + f"- Recurring themes so far: {common_tags}."
237 + ),
238 + ),
239 + "Top Repos This Month": RollupEntry(
240 + marker=marker,
241 + text=(
242 + f"{marker} — {page_link}\n"
243 + f"- {repo_link} led the published weekly analysis for {weekly.week}.\n"
244 + f"- Detailed breakdown: {page_link}."
245 + ),
246 + ),
247 + "Trends Observed": RollupEntry(
248 + marker=marker,
249 + text=(
250 + f"{marker} — {page_link}\n"
251 + f"- Signal: {weekly.signal}\n"
252 + f"- Noise: {weekly.noise}"
253 + ),
254 + ),
255 + "Key Takeaways": RollupEntry(
256 + marker=marker,
257 + text=(
258 + f"{marker} — {page_link}\n"
259 + f"- Gap to watch: {weekly.gaps}\n"
260 + f"- Closing read: {weekly.conclusion}"
261 + ),
262 + ),
263 + }
264 +
265 +
266 +def yearly_entries(weekly: WeeklySummary, tags_counter: Counter[str]) -> dict[str, RollupEntry]:
267 + common_tags = ", ".join(tag for tag, _ in tags_counter.most_common(5)) or "none yet"
268 + repo_link = repo_markdown(weekly.top_repo)
269 + month_link = f"[{weekly.month_title}]({weekly.month_link})"
270 + week_link = f"[{weekly.week_title}]({weekly.week_link})"
271 + marker = f"### {weekly.month_title} update — {weekly.week}"
272 + return {
273 + "Year in Review": RollupEntry(
274 + marker=marker,
275 + text=(
276 + f"{marker}\n"
277 + f"- {month_link} gained a new weekly signal via {week_link}.\n"
278 + f"- Snapshot: {weekly.summary}"
279 + ),
280 + ),
281 + "Biggest Trends": RollupEntry(
282 + marker=marker,
283 + text=(
284 + f"{marker}\n"
285 + f"- Themes in rotation: {common_tags}.\n"
286 + f"- Signal from {week_link}: {weekly.signal}"
287 + ),
288 + ),
289 + "Most Impactful Repos": RollupEntry(
290 + marker=marker,
291 + text=(
292 + f"{marker}\n"
293 + f"- Featured repo: {repo_link}.\n"
294 + f"- Month summary: {month_link}."
295 + ),
296 + ),
297 + "What Changed": RollupEntry(
298 + marker=marker,
299 + text=(
300 + f"{marker}\n"
301 + f"- Friction noted in {week_link}: {weekly.noise}"
302 + ),
303 + ),
304 + "Predictions Review": RollupEntry(
305 + marker=marker,
306 + text=(
307 + f"{marker}\n"
308 + f"- Open question carried forward from {month_link}: {weekly.gaps}\n"
309 + f"- Working takeaway: {weekly.conclusion}"
310 + ),
311 + ),
312 + }
313 +
314 +
315 +def build_monthly_pages(summaries: list[WeeklySummary], content_root: Path) -> list[RollupPage]:
316 + grouped: dict[tuple[int, int], list[WeeklySummary]] = defaultdict(list)
317 + for summary in summaries:
318 + grouped[(summary.year, summary.month)].append(summary)
319 +
320 + pages: list[RollupPage] = []
321 + for (year, month), items in sorted(grouped.items()):
322 + items = sorted(items, key=lambda item: (item.date, item.week))
323 + tags_counter: Counter[str] = Counter(tag for item in items for tag in item.tags)
324 + page_entries: dict[str, list[RollupEntry]] = {section: [] for section in MONTHLY_SECTIONS}
325 + for item in items:
326 + for section, entry in monthly_entries(item, tags_counter).items():
327 + page_entries[section].append(entry)
328 + pages.append(
329 + RollupPage(
330 + path=content_root / "monthly" / str(year) / f"{month:02d}.md",
331 + frontmatter={
332 + "title": f"{MONTH_NAMES[month]} {year} Rollup",
333 + "date": items[-1].date.isoformat(),
334 + "month": month,
335 + "year": year,
336 + "categories": ["monthly"],
337 + "weeks_covered": [item.week for item in items],
338 + "total_repos_featured": sum(item.repos_featured for item in items),
339 + },
340 + sections=page_entries,
341 + section_order=MONTHLY_SECTIONS,
342 + )
343 + )
344 + return pages
345 +
346 +
347 +def build_yearly_pages(summaries: list[WeeklySummary], content_root: Path) -> list[RollupPage]:
348 + grouped: dict[int, list[WeeklySummary]] = defaultdict(list)
349 + for summary in summaries:
350 + grouped[summary.year].append(summary)
351 +
352 + pages: list[RollupPage] = []
353 + for year, items in sorted(grouped.items()):
354 + items = sorted(items, key=lambda item: (item.date, item.week))
355 + tags_counter: Counter[str] = Counter(tag for item in items for tag in item.tags)
356 + page_entries: dict[str, list[RollupEntry]] = {section: [] for section in YEARLY_SECTIONS}
357 + for item in items:
358 + for section, entry in yearly_entries(item, tags_counter).items():
359 + page_entries[section].append(entry)
360 + months_covered = sorted({item.month_slug for item in items})
361 + pages.append(
362 + RollupPage(
363 + path=content_root / "yearly" / f"{year}.md",
364 + frontmatter={
365 + "title": f"{year} Yearly Rollup",
366 + "date": items[-1].date.isoformat(),
367 + "year": year,
368 + "categories": ["yearly"],
369 + "months_covered": months_covered,
370 + },
371 + sections=page_entries,
372 + section_order=YEARLY_SECTIONS,
373 + )
374 + )
375 + return pages
376 +
377 +
378 +def merge_sections(path: Path, section_order: list[str], new_entries: dict[str, list[RollupEntry]]) -> str:
379 + intro = ""
380 + existing_sections: dict[str, str] = {}
381 + if path.exists():
382 + existing_text = path.read_text(encoding="utf-8")
383 + try:
384 + _, body = analysis_gate.extract_frontmatter(existing_text)
385 + except ValueError:
386 + body = ""
387 + intro, existing_sections = split_sections(body)
388 +
389 + rendered_sections: list[str] = []
390 + for section in section_order:
391 + content = existing_sections.get(section, "")
392 + for entry in new_entries[section]:
393 + if entry.marker in content:
394 + continue
395 + content = f"{content.rstrip()}\n\n{entry.text}" if content.strip() else entry.text
396 + section_body = content.strip()
397 + if not section_body:
398 + section_body = "_No updates yet._"
399 + rendered_sections.append(f"## {section}\n\n{section_body}")
400 +
401 + if intro.strip():
402 + return intro.rstrip() + "\n\n" + "\n\n".join(rendered_sections) + "\n"
403 + return "\n\n".join(rendered_sections) + "\n"
404 +
405 +
406 +def write_rollup(page: RollupPage) -> None:
407 + page.path.parent.mkdir(parents=True, exist_ok=True)
408 + body = merge_sections(page.path, page.section_order, page.sections)
409 + page.path.write_text(render_frontmatter(page.frontmatter) + body, encoding="utf-8")
410 +
411 +
412 +def generate_rollups(analyzed_dir: Path, content_root: Path) -> list[Path]:
413 + summaries = load_weekly_summaries(analyzed_dir)
414 + written: list[Path] = []
415 + for page in [*build_monthly_pages(summaries, content_root), *build_yearly_pages(summaries, content_root)]:
416 + write_rollup(page)
417 + written.append(page.path)
418 + return written
419 +
420 +
421 +def main(argv: list[str] | None = None) -> int:
422 + args = parse_args(argv)
423 + written = generate_rollups(args.analyzed_dir, args.content_root)
424 + for path in written:
425 + print(f"Generated {path}")
426 + return 0
427 +
428 +
429 +if __name__ == "__main__":
430 + raise SystemExit(main())
tests/test_generate_rollups.py new
+171
@@ -0,0 +1,171 @@
1 +import tempfile
2 +import unittest
3 +from pathlib import Path
4 +
5 +import scripts.generate_rollups as generate_rollups
6 +
7 +
8 +def make_summary(*, week: str, date: str, top_repo: str, summary: str, signal: str, noise: str, gaps: str, conclusion: str) -> str:
9 + year = int(week[:4])
10 + return f'''---
11 +title: "Week {int(week[-2:])}, {year} Analysis"
12 +date: {date}
13 +week: "{week}"
14 +year: {year}
15 +tags: [ai, agents, developer-tooling]
16 +categories: [weekly]
17 +repos_featured: 10
18 +top_repo: "{top_repo}"
19 +quality_score: 80
20 +summary: "{summary}"
21 +stars_tracked: 1000
22 +---
23 +
24 +## Notable New Repositories
25 +
26 +A fresh set of launches landed.
27 +
28 +## Trending This Week
29 +
30 +Momentum concentrated around practical tooling.
31 +
32 +## Trend Analysis
33 +
34 +### Signal
35 +
36 +{signal}
37 +
38 +### Noise
39 +
40 +{noise}
41 +
42 +## What's Missing
43 +
44 +### Gaps
45 +
46 +{gaps}
47 +
48 +## Conclusion
49 +
50 +{conclusion}
51 +'''
52 +
53 +
54 +class GenerateRollupsTests(unittest.TestCase):
55 + def test_generate_rollups_creates_monthly_and_yearly_pages(self) -> None:
56 + tests_root = Path(__file__).resolve().parent
57 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
58 + base = Path(tmpdir)
59 + analyzed_dir = base / "data" / "analyzed"
60 + content_root = base / "content"
61 + analyzed_dir.mkdir(parents=True)
62 +
63 + (analyzed_dir / "2026-W21-summary.md").write_text(
64 + make_summary(
65 + week="2026-W21",
66 + date="2026-05-18T12:07:20+00:00",
67 + top_repo="octo/signal-kit",
68 + summary="Practical agent tooling led the week.",
69 + signal="Teams preferred operational automation over generic hype.",
70 + noise="Exploit-heavy projects still added editorial noise.",
71 + gaps="Reliable momentum data remained missing.",
72 + conclusion="The strongest projects made automation safer to adopt.",
73 + ),
74 + encoding="utf-8",
75 + )
76 +
77 + written = generate_rollups.generate_rollups(analyzed_dir, content_root)
78 +
79 + monthly_path = content_root / "monthly" / "2026" / "05.md"
80 + yearly_path = content_root / "yearly" / "2026.md"
81 + self.assertEqual(written, [monthly_path, yearly_path])
82 +
83 + monthly = monthly_path.read_text(encoding="utf-8")
84 + self.assertIn('title: "May 2026 Rollup"', monthly)
85 + self.assertIn('categories: ["monthly"]', monthly)
86 + self.assertIn('weeks_covered: ["2026-W21"]', monthly)
87 + self.assertIn('total_repos_featured: 10', monthly)
88 + self.assertIn('## Month Overview', monthly)
89 + self.assertIn('### Week 2026-W21', monthly)
90 + self.assertIn('[Week 21, 2026](/weekly/2026/W21/)', monthly)
91 + self.assertIn('[octo/signal-kit](https://github.com/octo/signal-kit)', monthly)
92 +
93 + yearly = yearly_path.read_text(encoding="utf-8")
94 + self.assertIn('title: "2026 Yearly Rollup"', yearly)
95 + self.assertIn('categories: ["yearly"]', yearly)
96 + self.assertIn('months_covered: ["2026-05"]', yearly)
97 + self.assertIn('## Year in Review', yearly)
98 + self.assertIn('### May 2026 update — 2026-W21', yearly)
99 + self.assertIn('[May 2026](/monthly/2026/05/)', yearly)
100 +
101 + def test_generate_rollups_is_append_only_for_existing_pages(self) -> None:
102 + tests_root = Path(__file__).resolve().parent
103 + with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
104 + base = Path(tmpdir)
105 + analyzed_dir = base / "data" / "analyzed"
106 + content_root = base / "content"
107 + analyzed_dir.mkdir(parents=True)
108 +
109 + (analyzed_dir / "2026-W21-summary.md").write_text(
110 + make_summary(
111 + week="2026-W21",
112 + date="2026-05-18T12:07:20+00:00",
113 + top_repo="octo/signal-kit",
114 + summary="Practical agent tooling led the week.",
115 + signal="Teams preferred operational automation over generic hype.",
116 + noise="Exploit-heavy projects still added editorial noise.",
117 + gaps="Reliable momentum data remained missing.",
118 + conclusion="The strongest projects made automation safer to adopt.",
119 + ),
120 + encoding="utf-8",
121 + )
122 + generate_rollups.generate_rollups(analyzed_dir, content_root)
123 + monthly_path = content_root / "monthly" / "2026" / "05.md"
124 + yearly_path = content_root / "yearly" / "2026.md"
125 + first_monthly = monthly_path.read_text(encoding="utf-8")
126 + first_yearly = yearly_path.read_text(encoding="utf-8")
127 +
128 + (analyzed_dir / "2026-W22-summary.md").write_text(
129 + make_summary(
130 + week="2026-W22",
131 + date="2026-05-25T12:07:20+00:00",
132 + top_repo="octo/steady-watch",
133 + summary="Observability and release safety gained more traction.",
134 + signal="Teams doubled down on measurable automation and release health.",
135 + noise="Wrapper projects still outnumbered differentiated platforms.",
136 + gaps="Defensive tooling still lagged behind orchestration tools.",
137 + conclusion="The durable winners reduced toil without hiding trade-offs.",
138 + ),
139 + encoding="utf-8",
140 + )
141 +
142 + generate_rollups.generate_rollups(analyzed_dir, content_root)
143 + second_monthly = monthly_path.read_text(encoding="utf-8")
144 + second_yearly = yearly_path.read_text(encoding="utf-8")
145 +
146 + self.assertIn('weeks_covered: ["2026-W21", "2026-W22"]', second_monthly)
147 + self.assertIn('months_covered: ["2026-05"]', second_yearly)
148 + for expected in [
149 + '### Week 2026-W21 — [Week 21, 2026](/weekly/2026/W21/)',
150 + '- [octo/signal-kit](https://github.com/octo/signal-kit) led the published weekly analysis for 2026-W21.',
151 + '- Signal: Teams preferred operational automation over generic hype.',
152 + '- Gap to watch: Reliable momentum data remained missing.',
153 + ]:
154 + self.assertIn(expected, second_monthly)
155 + for expected in [
156 + '### May 2026 update — 2026-W21',
157 + '- [May 2026](/monthly/2026/05/) gained a new weekly signal via [Week 21, 2026](/weekly/2026/W21/).',
158 + '- Featured repo: [octo/signal-kit](https://github.com/octo/signal-kit).',
159 + '- Working takeaway: The strongest projects made automation safer to adopt.',
160 + ]:
161 + self.assertIn(expected, second_yearly)
162 + self.assertEqual(second_monthly.count('### Week 2026-W21'), 4)
163 + self.assertEqual(second_monthly.count('### Week 2026-W22'), 4)
164 + self.assertEqual(second_yearly.count('### May 2026 update — 2026-W21'), 5)
165 + self.assertEqual(second_yearly.count('### May 2026 update — 2026-W22'), 5)
166 + self.assertNotEqual(first_monthly, second_monthly)
167 + self.assertNotEqual(first_yearly, second_yearly)
168 +
169 +
170 +if __name__ == "__main__":
171 + unittest.main()
tests/test_pipeline.py
+20
@@ -191,6 +191,26 @@ class WorkflowConfigTests(unittest.TestCase):
191 self.assertIn("mkdir -p .squad/skills .squad/reskill", reskill_run)
192 self.assertIn("trigger-log.txt", reskill_run)
193
194 + def test_generate_workflow_runs_rollups_and_commits_all_content(self) -> None:
195 + workflow_path = Path(".github/workflows/crawl-and-publish.yml")
196 + workflow = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
197 +
198 + generate_job = workflow["jobs"]["generate"]
199 + generate_rollups_step = next((s for s in generate_job["steps"] if s.get("name") == "Generate rollups"), None)
200 + self.assertIsNotNone(generate_rollups_step)
201 + self.assertEqual(generate_rollups_step["run"], "python3 scripts/generate_rollups.py")
202 +
203 + commit_step = next((s for s in generate_job["steps"] if s.get("name") == "Commit generated content"), None)
204 + self.assertIsNotNone(commit_step)
205 + commit_run = commit_step["run"]
206 + self.assertIn("content/weekly content/monthly content/yearly", commit_run)
207 + self.assertIn("git add content/weekly/ content/monthly/ content/yearly/", commit_run)
208 +
209 + upload_step = next((s for s in generate_job["steps"] if s.get("name") == "Upload generated content artifact"), None)
210 + self.assertIsNotNone(upload_step)
211 + self.assertIn("content/monthly/", upload_step["with"]["path"])
212 + self.assertIn("content/yearly/", upload_step["with"]["path"])
213 +
214
215 class PipelineIntegrationTests(unittest.TestCase):
216 def test_crawl_script_produces_valid_json_output_schema(self) -> None: