diff --git a/CLAUDE.md b/CLAUDE.md index 92e772a..13cc2e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,21 @@ The repository includes a justfile for convenient local development: 4. **GitHub Pages deployment**: Push changes to the main branch and GitHub Actions will automatically build and deploy via the `.github/workflows/deploy.yml` workflow +### Code Style and Linting + +**IMPORTANT**: This project enforces strict linting rules via ESLint. Always run the linter before committing: + +```bash +npm run lint # Check for linting errors +npm run lint:fix # Auto-fix linting errors where possible +``` + +Key linting requirements: +- **Use double quotes for strings** - NOT single quotes (TypeScript/JavaScript) +- The project uses ESLint with strict quote rules +- CI/CD will fail if linting errors are present +- Always verify with `npm run lint` before pushing changes + ### Content Structure #### Blog Posts @@ -75,11 +90,14 @@ src/content/blog/my-new-post/ Posts should include front matter with relevant metadata. #### Briefs (Short Notes) -Create brief notes in `src/content/briefs/` as individual markdown files: +Create brief notes in category subfolders within `src/content/briefs/`: ``` -src/content/briefs/my-brief.md +src/content/briefs/swift-warts/my-swift-brief.md +src/content/briefs/claude-code/my-claude-brief.md ``` +Categories are auto-discovered from folder names. To add a new category, simply create a new folder. You can optionally add a `category.yaml` file in the folder to customize the category metadata (display name, description, sort priority). + #### Projects Create project pages in `src/content/projects/` as folders with an `index.md` file: ``` diff --git a/package-lock.json b/package-lock.json index 98c0f78..be703ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "sharp": "^0.33.3", "tailwind-merge": "^2.2.2", "tailwindcss": "^3.4.1", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "yaml": "^2.8.1" }, "devDependencies": { "eslint-plugin-jsx-a11y": "^6.10.2" diff --git a/package.json b/package.json index 300bb53..e00152c 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "sharp": "^0.33.3", "tailwind-merge": "^2.2.2", "tailwindcss": "^3.4.1", - "typescript": "^5.4.2" + "typescript": "^5.4.2", + "yaml": "^2.8.1" }, "devDependencies": { "eslint-plugin-jsx-a11y": "^6.10.2" diff --git a/src/content/blog/meso-optimization/index.md b/src/content/blog/meso-optimization/index.md index 0b89d97..2750eba 100644 --- a/src/content/blog/meso-optimization/index.md +++ b/src/content/blog/meso-optimization/index.md @@ -47,7 +47,7 @@ When someone says an app feels "native" or "polished," they're often responding [^1]: Many of the best "native apps" aren't even native—there's lots of world-class, incredibly-polished Reactive Native out there. -The frustrating part? This stuff is hard to teach, hard to measure, and incredibly easy to break. One well-meaning refactor can silently undo a meso-optimization, or even several. [Your beautiful lazy sequence chains suddenly become eager](/briefs/lazy-sequences-decay-easily). Your carefully-managed view updates start firing twice. The app still works, all your tests pass, but somehow it doesn't feel quite as good anymore. +The frustrating part? This stuff is hard to teach, hard to measure, and incredibly easy to break. One well-meaning refactor can silently undo a meso-optimization, or even several. [Your beautiful lazy sequence chains suddenly become eager](/briefs/swift-warts/lazy-sequences-decay-easily). Your carefully-managed view updates start firing twice. The app still works, all your tests pass, but somehow it doesn't feel quite as good anymore. ## The Point of All This diff --git a/src/content/briefs/claude-code/category.yaml b/src/content/briefs/claude-code/category.yaml new file mode 100644 index 0000000..ed047f9 --- /dev/null +++ b/src/content/briefs/claude-code/category.yaml @@ -0,0 +1,4 @@ +displayName: "Claude Code" +titlePrefix: "Claude Code" +description: "Briefs about using and optimizing Claude Code, the agentic coding assistant" +sortPriority: 2 \ No newline at end of file diff --git a/src/content/briefs/claude-code-never-compact.md b/src/content/briefs/claude-code/claude-code-never-compact.md similarity index 96% rename from src/content/briefs/claude-code-never-compact.md rename to src/content/briefs/claude-code/claude-code-never-compact.md index 824f045..20102a9 100644 --- a/src/content/briefs/claude-code-never-compact.md +++ b/src/content/briefs/claude-code/claude-code-never-compact.md @@ -1,7 +1,6 @@ --- title: "Never `compact`" description: "Claude Code's `/compact` command exists and seems useful, but should *rarely* be used." -category: "Claude Code" date: "2025-07-25" --- diff --git a/src/content/briefs/claude-code-pricing.md b/src/content/briefs/claude-code/claude-code-pricing.md similarity index 98% rename from src/content/briefs/claude-code-pricing.md rename to src/content/briefs/claude-code/claude-code-pricing.md index c1424a1..e8b8ea9 100644 --- a/src/content/briefs/claude-code-pricing.md +++ b/src/content/briefs/claude-code/claude-code-pricing.md @@ -2,7 +2,6 @@ title: "Claude Code's Subscription Pricing vs GPT-5" cardTitle: "Subscription Pricing vs GPT-5" description: "Anthropics subscriptions are economical, but so GPT-5." -category: "Claude Code" date: "2025-08-08" --- diff --git a/src/content/briefs/swift-warts/category.yaml b/src/content/briefs/swift-warts/category.yaml new file mode 100644 index 0000000..8bcad35 --- /dev/null +++ b/src/content/briefs/swift-warts/category.yaml @@ -0,0 +1,4 @@ +displayName: "Swift Warts" +titlePrefix: "Swift Wart" +description: "Briefs about unfortunate pain points in the Swift language and standard library" +sortPriority: 3 \ No newline at end of file diff --git a/src/content/briefs/first-trailing-closure-cannot-have-a-label.md b/src/content/briefs/swift-warts/first-trailing-closure-cannot-have-a-label.md similarity index 99% rename from src/content/briefs/first-trailing-closure-cannot-have-a-label.md rename to src/content/briefs/swift-warts/first-trailing-closure-cannot-have-a-label.md index 7fa61c2..2f9168c 100644 --- a/src/content/briefs/first-trailing-closure-cannot-have-a-label.md +++ b/src/content/briefs/swift-warts/first-trailing-closure-cannot-have-a-label.md @@ -2,7 +2,6 @@ title: "Swift's First Trailing Closure Cannot Have a Label" cardTitle: "First Trailing Closure Cannot Have a Label" description: "Swift forbids labeling the first trailing closure, which can be a surprising obstacle to idiomatic API design." -category: "Swift Wart" date: "2025-07-16" --- diff --git a/src/content/briefs/lazy-sequences-decay-easily.md b/src/content/briefs/swift-warts/lazy-sequences-decay-easily.md similarity index 98% rename from src/content/briefs/lazy-sequences-decay-easily.md rename to src/content/briefs/swift-warts/lazy-sequences-decay-easily.md index f4c05e4..b49c045 100644 --- a/src/content/briefs/lazy-sequences-decay-easily.md +++ b/src/content/briefs/swift-warts/lazy-sequences-decay-easily.md @@ -1,7 +1,6 @@ --- title: "Lazy Sequences \"Decay\" Easily" description: "Swift's lazy functional API is elegant, but prone to silently decaying back to its eager equivalent." -category: "Swift Wart" date: "2025-07-15" --- diff --git a/src/content/briefs/lazy-sequences-lack-primary-associated-types.md b/src/content/briefs/swift-warts/lazy-sequences-lack-primary-associated-types.md similarity index 93% rename from src/content/briefs/lazy-sequences-lack-primary-associated-types.md rename to src/content/briefs/swift-warts/lazy-sequences-lack-primary-associated-types.md index 8c0a0ea..7d4df30 100644 --- a/src/content/briefs/lazy-sequences-lack-primary-associated-types.md +++ b/src/content/briefs/swift-warts/lazy-sequences-lack-primary-associated-types.md @@ -1,7 +1,6 @@ --- title: "Lazy Sequences Lack Primary Associated Types" description: "You can write `Sequence` but not `LazySequenceProtocol`" -category: "Swift Wart" date: "2025-07-18" --- diff --git a/src/content/briefs/name-collisions-in-result-builders.md b/src/content/briefs/swift-warts/name-collisions-in-result-builders.md similarity index 98% rename from src/content/briefs/name-collisions-in-result-builders.md rename to src/content/briefs/swift-warts/name-collisions-in-result-builders.md index febb808..288db5b 100644 --- a/src/content/briefs/name-collisions-in-result-builders.md +++ b/src/content/briefs/swift-warts/name-collisions-in-result-builders.md @@ -1,7 +1,6 @@ --- title: "Name Collisions in Result Builders" description: "You get a `ForEach`, and you get a `ForEach`, and..." -category: "Swift Wart" date: "2025-07-17" --- diff --git a/src/content/briefs/no-concept-of-generic-actor-isolation-parameters.md b/src/content/briefs/swift-warts/no-concept-of-generic-actor-isolation-parameters.md similarity index 97% rename from src/content/briefs/no-concept-of-generic-actor-isolation-parameters.md rename to src/content/briefs/swift-warts/no-concept-of-generic-actor-isolation-parameters.md index 25d1fe8..c437198 100644 --- a/src/content/briefs/no-concept-of-generic-actor-isolation-parameters.md +++ b/src/content/briefs/swift-warts/no-concept-of-generic-actor-isolation-parameters.md @@ -2,7 +2,6 @@ title: "Swift Cannot Express Generic Actor Isolation Parameters" cardTitle: "You Cannot Express Generic Actor Isolation Parameters" description: "Swift lets you write `@MainActor` and `@SomeOtherActor`, but not `@'A`, where `A` is a generic actor parameter." -category: "Swift Wart" date: "2025-07-20" --- diff --git a/src/content/briefs/sendable-custom-cow-and-lazy-cached-properties.md b/src/content/briefs/swift-warts/sendable-custom-cow-and-lazy-cached-properties.md similarity index 99% rename from src/content/briefs/sendable-custom-cow-and-lazy-cached-properties.md rename to src/content/briefs/swift-warts/sendable-custom-cow-and-lazy-cached-properties.md index 9234693..07722c4 100644 --- a/src/content/briefs/sendable-custom-cow-and-lazy-cached-properties.md +++ b/src/content/briefs/swift-warts/sendable-custom-cow-and-lazy-cached-properties.md @@ -1,7 +1,6 @@ --- title: "Lazy Sequences Lack Primary Associated Types" description: "You can write `Sequence` but not `LazySequenceProtocol`" -category: "Swift Wart" date: "2025-07-18" --- diff --git a/src/content/config.ts b/src/content/config.ts index 3bca2d1..a45462e 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -20,7 +20,6 @@ const briefs = defineCollection({ title: z.string(), cardTitle: z.string().optional(), description: z.string(), - category: z.string(), date: z.coerce.date(), draft: z.boolean().optional() }).transform((data) => ({ diff --git a/src/lib/category.ts b/src/lib/category.ts new file mode 100644 index 0000000..bde4f33 --- /dev/null +++ b/src/lib/category.ts @@ -0,0 +1,96 @@ +import { readFileSync, existsSync } from "fs"; +import { join } from "path"; +import { parse } from "yaml"; + +export interface BriefCategory { + slug: string; + displayName: string; + titlePrefix?: string; + description?: string; + sortPriority?: number; +} + +/** + * Convert a kebab-case slug to a display name + * e.g., "swift-warts" -> "Swift Warts" + */ +function slugToDisplayName(slug: string): string { + return slug + .split("-") + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * Create a default category from a directory name + */ +export function getCategoryFromSlug(slug: string): BriefCategory { + const displayName = slugToDisplayName(slug); + return { + slug, + displayName, + titlePrefix: displayName, + sortPriority: 0 + }; +} + +/** + * Load category overrides from a category.yaml file if it exists + */ +export function loadCategoryOverrides(categoryPath: string): Partial | null { + const overridePath = join(categoryPath, "category.yaml"); + + if (!existsSync(overridePath)) { + return null; + } + + try { + const content = readFileSync(overridePath, "utf-8"); + return parse(content) as Partial; + } catch (error) { + console.warn(`Failed to parse category.yaml in ${categoryPath}:`, error); + return null; + } +} + +/** + * Get the full category metadata, combining defaults with overrides + */ +export function getCategory(slug: string, categoryPath: string): BriefCategory { + const defaultCategory = getCategoryFromSlug(slug); + const overrides = loadCategoryOverrides(categoryPath); + + if (!overrides) { + return defaultCategory; + } + + return { + ...defaultCategory, + ...overrides, + slug // Always preserve the original slug + }; +} + +/** + * Extract category slug from a brief's slug + * e.g., "swift-warts/lazy-sequences" -> "swift-warts" + */ +export function extractCategoryFromSlug(briefSlug: string): string | null { + const parts = briefSlug.split("/"); + if (parts.length > 1) { + return parts[0]; + } + return null; // Uncategorized brief +} + +/** + * Extract the brief's relative slug within its category + * e.g., "swift-warts/lazy-sequences" -> "lazy-sequences" + */ +export function extractBriefSlugFromPath(fullSlug: string): string { + const parts = fullSlug.split("/"); + if (parts.length > 1) { + return parts.slice(1).join("/"); + } + return fullSlug; // Uncategorized brief +} \ No newline at end of file diff --git a/src/lib/contentCardHelpers.ts b/src/lib/contentCardHelpers.ts index 6b9ee5c..9456d19 100644 --- a/src/lib/contentCardHelpers.ts +++ b/src/lib/contentCardHelpers.ts @@ -1,4 +1,5 @@ import type { CollectionEntry } from "astro:content"; +import { extractCategoryFromSlug, getCategory } from "./category"; /** * Transform a blog or project entry into ContentCard props @@ -36,8 +37,17 @@ export function getProjectCardProps(entry: CollectionEntry<"blog"> | CollectionE export function getBriefCardProps(entry: CollectionEntry<"briefs">, includeCategory = true, maxLines?: number | "none") { const displayTitle = entry.data.cardTitle || entry.data.title; + // Extract category from slug path + const categorySlug = extractCategoryFromSlug(entry.slug); + let categoryPrefix: string | undefined; + + if (includeCategory && categorySlug) { + const category = getCategory(categorySlug, `src/content/briefs/${categorySlug}`); + categoryPrefix = category.titlePrefix || category.displayName; + } + return { - titlePrefix: includeCategory ? entry.data.category : undefined, + titlePrefix: categoryPrefix, title: displayTitle, subtitle: entry.data.description, link: `/${entry.collection}/${entry.slug}`, diff --git a/src/pages/briefs/[...slug].astro b/src/pages/briefs/[...slug].astro index bc6cd90..e37dc3a 100644 --- a/src/pages/briefs/[...slug].astro +++ b/src/pages/briefs/[...slug].astro @@ -4,6 +4,7 @@ import PageLayout from "@layouts/PageLayout.astro"; import Container from "@components/Container.astro"; import FormattedDate from "@components/FormattedDate.astro"; import BackToPrev from "@components/BackToPrev.astro"; +import { extractCategoryFromSlug, getCategory } from "@lib/category"; export async function getStaticPaths() { const briefs = (await getCollection("briefs")) @@ -18,13 +19,17 @@ type Props = CollectionEntry<"briefs">; const brief = Astro.props; const { Content } = await brief.render(); + +// Extract category from the slug +const categorySlug = extractCategoryFromSlug(brief.slug); +const category = categorySlug ? getCategory(categorySlug, `src/content/briefs/${categorySlug}`) : null; ---
- - Back to briefs + + Back to {categorySlug ? category?.displayName + " briefs" : "briefs"}
@@ -32,10 +37,14 @@ const { Content } = await brief.render();
- • -
- {brief.data.category} -
+ {category && ( + <> + • +
+ {category.titlePrefix || category.displayName} +
+ + )}
{brief.data.title} diff --git a/src/pages/briefs/[category].astro b/src/pages/briefs/[category].astro new file mode 100644 index 0000000..220f3f0 --- /dev/null +++ b/src/pages/briefs/[category].astro @@ -0,0 +1,79 @@ +--- +import { type CollectionEntry, getCollection } from "astro:content"; +import PageLayout from "@layouts/PageLayout.astro"; +import Container from "@components/Container.astro"; +import ContentCard from "@components/ContentCard.astro"; +import { getBriefCardProps } from "@lib/contentCardHelpers"; +import { getCategory, extractCategoryFromSlug } from "@lib/category"; +import BackToPrev from "@components/BackToPrev.astro"; + +export async function getStaticPaths() { + const allBriefs = (await getCollection("briefs")) + .filter(brief => !brief.data.draft); + + // Get unique categories from brief slugs + const categories = new Set(); + allBriefs.forEach(brief => { + const category = extractCategoryFromSlug(brief.slug); + if (category) { + categories.add(category); + } + }); + + // Create paths for each category + return Array.from(categories).map(category => ({ + params: { category }, + props: { + category, + briefs: allBriefs + .filter(brief => extractCategoryFromSlug(brief.slug) === category) + .sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()) + } + })); +} + +interface Props { + category: string; + briefs: CollectionEntry<"briefs">[]; +} + +const { category: categorySlug, briefs } = Astro.props; +const categoryPath = `src/content/briefs/${categorySlug}`; +const category = getCategory(categorySlug, categoryPath); + +const renderedBriefs = await Promise.all( + briefs.map(async (item) => { + const { Content } = await item.render(); + return { ...item, Content }; + }) +); +--- + + + +
+
+ + Back to all briefs + +
+
+

+ {category.displayName} +

+ {category.description && ( +

+ {category.description} +

+ )} +
+
    + {renderedBriefs.map((brief) => ( +
  • + +
  • + ))} +
+
+
+
\ No newline at end of file diff --git a/src/pages/briefs/index.astro b/src/pages/briefs/index.astro index a2328d4..1c5104e 100644 --- a/src/pages/briefs/index.astro +++ b/src/pages/briefs/index.astro @@ -3,7 +3,9 @@ import { type CollectionEntry, getCollection } from "astro:content"; import PageLayout from "@layouts/PageLayout.astro"; import Container from "@components/Container.astro"; import ContentCard from "@components/ContentCard.astro"; +import Link from "@components/Link.astro"; import { getBriefCardProps } from "@lib/contentCardHelpers"; +import { extractCategoryFromSlug, getCategory } from "@lib/category"; import { BRIEFS } from "@consts"; const collection = (await getCollection("briefs")) @@ -21,38 +23,46 @@ type BriefList = CollectionEntry<"briefs">[]; type Brief = CollectionEntry<"briefs">; type BriefsByCategory = { - [category: string]: BriefList; + [category: string]: { + briefs: BriefList; + metadata: ReturnType; + }; } +// Group briefs by category const briefs_by_category: BriefsByCategory = briefs.reduce((acc: BriefsByCategory, brief) => { - const category = brief.data.category; - if (!acc[category]) { - acc[category] = []; + const categorySlug = extractCategoryFromSlug(brief.slug); + const categoryKey = categorySlug || 'uncategorized'; + + if (!acc[categoryKey]) { + const metadata = categorySlug + ? getCategory(categorySlug, `src/content/briefs/${categorySlug}`) + : { slug: 'uncategorized', displayName: 'Uncategorized', sortPriority: -1 }; + + acc[categoryKey] = { + briefs: [], + metadata + }; } - acc[category].push(brief); + + acc[categoryKey].briefs.push(brief); return acc; }, {}); -const brief_order_cheats: { [key: string]: number } = { - "Swift Wart": 3, - "Claude Code": 2, - "Meso-Optimization": 1, -}; - -const brief_title_overrides: { [key: string]: string } = { - "Swift Wart": "Swift Warts" -}; - +// Sort categories by priority, then alphabetically const brief_categories = Object.keys(briefs_by_category) .sort((a, b) => { - // - const a_order = brief_order_cheats[a] || 0; - const b_order = brief_order_cheats[b] || 0; - if (a_order !== b_order) { - return b_order - a_order; + const a_priority = briefs_by_category[a].metadata.sortPriority || 0; + const b_priority = briefs_by_category[b].metadata.sortPriority || 0; + + if (a_priority !== b_priority) { + return b_priority - a_priority; } - // alphabetic (ascending) as fallback when there's no order-cheat - return a.localeCompare(b) + + // alphabetic (ascending) as fallback + return briefs_by_category[a].metadata.displayName.localeCompare( + briefs_by_category[b].metadata.displayName + ); }); --- @@ -65,28 +75,43 @@ const brief_categories = Object.keys(briefs_by_category)
    { - brief_categories.map(category => ( -
  • -
    - { brief_title_overrides[category] ?? category } -
    -
      - { - briefs_by_category[category].map((brief: Brief) => ( -
    • - -
    • - )) - } -
    -
  • - )) + brief_categories.map(categoryKey => { + const { briefs: categoryBriefs, metadata } = briefs_by_category[categoryKey]; + const hasCategory = categoryKey !== 'uncategorized'; + + return ( +
  • +
    +
    + { metadata.displayName } +
    + {hasCategory && ( + + + View all → + + + )} +
    + {metadata.description && ( +

    + {metadata.description} +

    + )} +
      + { + categoryBriefs.map((brief: Brief) => ( +
    • + +
    • + )) + } +
    +
  • + ); + }) }
-
-
+ \ No newline at end of file