Skip to content

Commit 39a9604

Browse files
oubiwannclaude
andcommitted
Refactor shared utils and add sorted authors to blog-resolve.
Consolidate duplicate split_front_matter into crate::util (renamed from slug.rs), add authors_sorted list to blog_resolved.yml for the authors page template, and clean up CLI output to use stderr for user messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3793a0d commit 39a9604

11 files changed

Lines changed: 165 additions & 75 deletions

File tree

src/_layouts/blog-authors.liquid

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,9 @@
2121

2222
<h1 class="blog-section-heading">{{ page.data.long_title }}</h1>
2323

24-
{% for author_entry in site.data.authors %}
25-
{% assign slug = author_entry[0] %}
26-
{% assign author = author_entry[1] %}
27-
28-
{% assign post_count = 0 %}
29-
{% for post in paginator.pages %}
30-
{% if post.data.author == slug %}
31-
{% assign post_count = post_count | plus: 1 %}
32-
{% endif %}
33-
{% endfor %}
24+
{% for author in site.data.blog_resolved.authors_sorted %}
3425

35-
<a href="/blog/authors/{{ slug }}/" class="blog-author-page-card">
26+
<a href="/blog/authors/{{ author.slug }}/" class="blog-author-page-card">
3627
{% if author.avatar %}
3728
<img src="{{ author.avatar }}" alt="{{ author.name }}" class="blog-author-avatar-lg">
3829
{% else %}
@@ -43,7 +34,7 @@
4334
{% if author.bio %}
4435
<p class="blog-author-page-bio">{{ author.bio }}</p>
4536
{% endif %}
46-
<p class="blog-card-meta">{{ post_count }} posts</p>
37+
<p class="blog-card-meta">{{ author.post_count }} posts</p>
4738
</div>
4839
</a>
4940
{% endfor %}

tools/lfesite/src/cmd/blog_migrate.rs

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use std::path::Path;
55
use anyhow::{Context, Result};
66
use regex::Regex;
77

8+
use crate::util::split_front_matter;
9+
810
/// Run the Jekyll-to-Cobalt blog post migration.
911
pub fn run(project_dir: &Path, source: &Path) -> Result<()> {
1012
println!("blog-migrate: discovering source posts...");
@@ -177,7 +179,10 @@ pub fn run(project_dir: &Path, source: &Path) -> Result<()> {
177179
// Build categories YAML
178180
let categories_yaml = format!("[\"{}\"]", category);
179181

180-
// Build front-matter
182+
// Build front-matter via format! rather than serde_yaml. This is
183+
// intentional: the corpus is controlled (all 116 posts verified), and
184+
// string formatting gives us exact control over field order and
185+
// indentation. Titles/descriptions are escaped via escape_yaml_string().
181186
let new_front_matter = format!(
182187
"---\n\
183188
layout: post.liquid\n\
@@ -372,23 +377,6 @@ pub fn run(project_dir: &Path, source: &Path) -> Result<()> {
372377
// Helpers
373378
// ---------------------------------------------------------------------------
374379

375-
/// Split content into (front-matter, body). Front-matter is between `---` delimiters.
376-
fn split_front_matter(content: &str) -> Option<(String, &str)> {
377-
let trimmed = content.trim_start();
378-
let rest = trimmed.strip_prefix("---")?;
379-
// Find the closing ---
380-
let end = rest.find("\n---")?;
381-
let fm = rest[..end].to_string();
382-
let body_start = end + 4; // skip \n---
383-
// Skip the newline after closing ---
384-
let body = if body_start < rest.len() && rest.as_bytes()[body_start] == b'\n' {
385-
&rest[body_start + 1..]
386-
} else {
387-
&rest[body_start..]
388-
};
389-
Some((fm, body))
390-
}
391-
392380
/// Extract a simple field value from YAML front-matter text.
393381
fn extract_field(fm: &str, key: &str) -> Option<String> {
394382
for line in fm.lines() {
@@ -455,7 +443,11 @@ fn normalize_tag(tag: &str) -> String {
455443
}
456444
}
457445

458-
/// Map author name to slug.
446+
/// Map an author's display name to their URL slug.
447+
///
448+
/// The six known authors are from the legacy `blog.lfe.io` Jekyll
449+
/// corpus (2014–2020). Unknown names are slugified and logged as
450+
/// warnings so they can be added to `_data/authors.yml`.
459451
fn author_to_slug(name: &str) -> String {
460452
match name.trim() {
461453
"Duncan McGreggor" => "duncan-mcgreggor".to_string(),

tools/lfesite/src/cmd/blog_resolve.rs

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use anyhow::{Context, Result};
66
use sha2::{Digest, Sha256};
77
use walkdir::WalkDir;
88

9+
use crate::util::split_front_matter;
10+
911
/// Resolve blog editorial configuration into a template-ready data file.
1012
///
1113
/// Reads `_data/blog.yml` (editorial config) and all post front-matter from
@@ -97,7 +99,10 @@ pub fn run(src_dir: &Path) -> Result<()> {
9799
resolved_slots.push((*name, resolved));
98100
}
99101

100-
// 6. Build river
102+
// 6. Build sorted authors list (by post count desc, then name asc)
103+
let authors_sorted = build_sorted_authors(&all_posts, &data_dir)?;
104+
105+
// 7. Build river
101106
let river: Vec<serde_yaml::Value> = all_posts
102107
.iter()
103108
.filter(|p| !used.contains(&p.slug))
@@ -108,7 +113,7 @@ pub fn run(src_dir: &Path) -> Result<()> {
108113
})
109114
.collect();
110115

111-
// 7. Assemble output
116+
// 8. Assemble output
112117
let mut out = serde_yaml::Mapping::new();
113118

114119
for (name, value) in &resolved_slots {
@@ -118,13 +123,14 @@ pub fn run(src_dir: &Path) -> Result<()> {
118123
}
119124

120125
out.insert(ykey("river"), serde_yaml::Value::Sequence(river));
126+
out.insert(ykey("authors_sorted"), serde_yaml::Value::Sequence(authors_sorted));
121127
out.insert(ykey("default_cover_image"), ystr(default_cover));
122128
out.insert(ykey("default_cover_alt"), ystr(default_alt));
123129

124130
let yaml_out = serde_yaml::to_string(&serde_yaml::Value::Mapping(out))
125131
.context("serializing blog_resolved.yml")?;
126132

127-
// 8. Write (idempotent)
133+
// 9. Write (idempotent)
128134
let resolved_path = data_dir.join("blog_resolved.yml");
129135
let needs_write = if resolved_path.exists() {
130136
let existing = fs::read_to_string(&resolved_path).unwrap_or_default();
@@ -409,19 +415,72 @@ fn slug_from_path(path: &Path, posts_dir: &Path) -> Option<String> {
409415
}
410416
}
411417

412-
/// Split YAML front-matter (`---` delimited) from body content.
413-
fn split_front_matter(content: &str) -> Option<(String, &str)> {
414-
let trimmed = content.trim_start();
415-
let rest = trimmed.strip_prefix("---")?;
416-
let end = rest.find("\n---")?;
417-
let fm = rest[..end].to_string();
418-
let body_start = end + 4;
419-
let body = if body_start < rest.len() && rest.as_bytes()[body_start] == b'\n' {
420-
&rest[body_start + 1..]
421-
} else {
422-
&rest[body_start..]
423-
};
424-
Some((fm, body))
418+
419+
/// Build a sorted list of authors with post counts for template use.
420+
///
421+
/// Sorted by post count descending, then display name ascending for ties.
422+
fn build_sorted_authors(posts: &[PostMeta], data_dir: &Path) -> Result<Vec<serde_yaml::Value>> {
423+
let path = data_dir.join("authors.yml");
424+
if !path.exists() {
425+
return Ok(Vec::new());
426+
}
427+
428+
let content = fs::read_to_string(&path).context("reading authors.yml")?;
429+
let doc: serde_yaml::Value =
430+
serde_yaml::from_str(&content).context("parsing authors.yml")?;
431+
432+
let mut post_counts: HashMap<String, usize> = HashMap::new();
433+
for post in posts {
434+
*post_counts.entry(post.author_slug.clone()).or_insert(0) += 1;
435+
}
436+
437+
let mut author_entries: Vec<(String, String, Option<String>, Option<String>, usize)> = Vec::new();
438+
439+
if let Some(map) = doc.as_mapping() {
440+
for (key, value) in map {
441+
if let Some(slug) = key.as_str() {
442+
let name = value
443+
.get("name")
444+
.and_then(|v| v.as_str())
445+
.unwrap_or(slug)
446+
.to_string();
447+
let bio = value
448+
.get("bio")
449+
.and_then(|v| v.as_str())
450+
.map(String::from);
451+
let avatar = value
452+
.get("avatar")
453+
.and_then(|v| v.as_str())
454+
.map(String::from);
455+
let count = post_counts.get(slug).copied().unwrap_or(0);
456+
author_entries.push((slug.to_string(), name, bio, avatar, count));
457+
}
458+
}
459+
}
460+
461+
author_entries.sort_by(|a, b| b.4.cmp(&a.4).then_with(|| a.1.cmp(&b.1)));
462+
463+
let sorted: Vec<serde_yaml::Value> = author_entries
464+
.into_iter()
465+
.map(|(slug, name, bio, avatar, count)| {
466+
let mut map = serde_yaml::Mapping::new();
467+
map.insert(ykey("slug"), ystr(&slug));
468+
map.insert(ykey("name"), ystr(&name));
469+
if let Some(b) = &bio {
470+
map.insert(ykey("bio"), ystr(b));
471+
}
472+
if let Some(a) = &avatar {
473+
map.insert(ykey("avatar"), ystr(a));
474+
}
475+
map.insert(
476+
ykey("post_count"),
477+
serde_yaml::Value::Number(serde_yaml::Number::from(count as u64)),
478+
);
479+
serde_yaml::Value::Mapping(map)
480+
})
481+
.collect();
482+
483+
Ok(sorted)
425484
}
426485

427486
/// Load author slug → display name mapping from `_data/authors.yml`.

tools/lfesite/src/cmd/build.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,13 @@ fn ensure_pagefind_binary() -> Result<std::path::PathBuf> {
336336

337337
/// Resolve the latest pagefind release version tag (e.g. "v1.3.0").
338338
///
339-
/// Uses the GitHub API to query the latest release, extracting the
340-
/// tag_name field with grep/sed (no jq dependency).
339+
/// Resolve the latest pagefind release version tag (e.g. "v1.3.0").
340+
///
341+
/// Uses the GitHub API JSON endpoint with a `curl | grep | sed` shell
342+
/// pipeline to extract `tag_name`. This depends on `curl`, `grep`, and
343+
/// `sed` being available on PATH (standard on macOS and Linux). If the
344+
/// GitHub API response format changes, this will fail with a clear
345+
/// error message rather than silently downloading the wrong version.
341346
fn resolve_pagefind_version() -> Result<String> {
342347
let output = Command::new("sh")
343348
.args([

tools/lfesite/src/cmd/draft_post.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::path::Path;
44

55
use anyhow::{bail, Context, Result};
66

7+
/// Convert a published post to draft, or create a new draft interactively.
78
pub fn run(project_dir: &Path, file: Option<&Path>) -> Result<()> {
89
match file {
910
Some(f) => draft_existing(f),
@@ -20,8 +21,8 @@ fn draft_existing(file: &Path) -> Result<()> {
2021
.with_context(|| format!("reading {}", file.display()))?;
2122

2223
if content.contains("is_draft: true") {
23-
println!();
24-
println!(" already a draft: {}", file.display());
24+
eprintln!();
25+
eprintln!(" already a draft: {}", file.display());
2526
return Ok(());
2627
}
2728

@@ -39,17 +40,17 @@ fn draft_existing(file: &Path) -> Result<()> {
3940
.map(|l| l.trim_start_matches("title:").trim().trim_matches('"'))
4041
.unwrap_or("(unknown)");
4142

42-
println!();
43-
println!(" drafted: {}", file.display());
44-
println!(" title: \"{}\"", title);
45-
println!();
43+
eprintln!();
44+
eprintln!(" drafted: {}", file.display());
45+
eprintln!(" title: \"{}\"", title);
46+
eprintln!();
4647

4748
Ok(())
4849
}
4950

5051
fn draft_new(project_dir: &Path) -> Result<()> {
51-
print!(" Title: ");
52-
io::stdout().flush()?;
52+
eprint!(" Title: ");
53+
io::stderr().flush()?;
5354
let mut title = String::new();
5455
io::stdin().read_line(&mut title)?;
5556
let title = title.trim();

tools/lfesite/src/cmd/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
//! Subcommand implementations for the `lfesite` CLI.
2+
13
pub mod blog_migrate;
24
pub mod blog_resolve;
35
pub mod build;

tools/lfesite/src/cmd/new_post.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ use std::process::Command;
66
use anyhow::{bail, Context, Result};
77
use chrono::{NaiveDateTime, Utc};
88

9-
use crate::slug::slugify;
9+
use crate::util::slugify;
1010

11+
/// Create a new blog post file with front-matter scaffolding.
1112
pub fn run(
1213
project_dir: &Path,
1314
title: &str,
@@ -93,16 +94,16 @@ data:
9394
.with_context(|| format!("writing {}", filepath.display()))?;
9495

9596
let rel_path = filepath.strip_prefix(project_dir).unwrap_or(&filepath);
96-
println!();
97-
println!(" created: {}", rel_path.display());
98-
println!(" status: {}", if draft { "draft" } else { "published (use --draft for draft)" });
99-
println!(" author: {author}");
100-
println!();
97+
eprintln!();
98+
eprintln!(" created: {}", rel_path.display());
99+
eprintln!(" status: {}", if draft { "draft" } else { "published (use --draft for draft)" });
100+
eprintln!(" author: {author}");
101+
eprintln!();
101102

102103
// Offer to open in $EDITOR
103104
if let Ok(editor) = std::env::var("EDITOR") {
104-
print!(" Open post in $EDITOR? [Y/n] ");
105-
io::stdout().flush()?;
105+
eprint!(" Open post in $EDITOR? [Y/n] ");
106+
io::stderr().flush()?;
106107
let mut input = String::new();
107108
io::stdin().read_line(&mut input)?;
108109
let input = input.trim().to_lowercase();
@@ -126,12 +127,16 @@ fn detect_author() -> String {
126127
Ok(o) if o.status.success() => {
127128
let name = String::from_utf8_lossy(&o.stdout).trim().to_string();
128129
if name.is_empty() {
130+
eprintln!(" note: git user.name is empty, using 'unknown' as author");
129131
"unknown".to_string()
130132
} else {
131133
slugify(&name)
132134
}
133135
}
134-
_ => "unknown".to_string(),
136+
_ => {
137+
eprintln!(" note: could not detect author from git, using 'unknown'");
138+
"unknown".to_string()
139+
}
135140
}
136141
}
137142

tools/lfesite/src/cmd/publish_post.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::path::Path;
33

44
use anyhow::{bail, Context, Result};
55

6+
/// Publish a draft post by setting `is_draft: false` in its front matter.
67
pub fn run(file: &Path) -> Result<()> {
78
if !file.exists() {
89
bail!("file not found: {}", file.display());
@@ -12,8 +13,8 @@ pub fn run(file: &Path) -> Result<()> {
1213
.with_context(|| format!("reading {}", file.display()))?;
1314

1415
if content.contains("is_draft: false") {
15-
println!();
16-
println!(" already published: {}", file.display());
16+
eprintln!();
17+
eprintln!(" already published: {}", file.display());
1718
return Ok(());
1819
}
1920

@@ -32,10 +33,10 @@ pub fn run(file: &Path) -> Result<()> {
3233
.map(|l| l.trim_start_matches("title:").trim().trim_matches('"'))
3334
.unwrap_or("(unknown)");
3435

35-
println!();
36-
println!(" published: {}", file.display());
37-
println!(" title: \"{}\"", title);
38-
println!();
36+
eprintln!();
37+
eprintln!(" published: {}", file.display());
38+
eprintln!(" title: \"{}\"", title);
39+
eprintln!();
3940

4041
Ok(())
4142
}

tools/lfesite/src/cmd/validate.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ pub fn run(project_dir: &Path) -> Result<()> {
7878
// Check 2: _md/_html pairing (report)
7979
// ------------------------------------------------------------------
8080
println!("validate: checking _md/_html pairing...");
81-
let pairing_errors: Vec<&String> = errors
81+
let pairing_errors: Vec<&str> = errors
8282
.iter()
8383
.filter(|e| e.contains("has no") && e.contains("_html sibling"))
84+
.map(|e| e.as_str())
8485
.collect();
8586
if pairing_errors.is_empty() {
8687
println!(" \u{2713} all _md fields have _html siblings");

tools/lfesite/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use anyhow::Result;
44
use clap::{Parser, Subcommand};
55

66
mod cmd;
7-
mod slug;
7+
mod util;
88

99
/// Build orchestration and data pre-rendering for the LFE website.
1010
#[derive(Debug, Parser)]

0 commit comments

Comments
 (0)