Repository: TehcCringe/TehcCringe
Pull request #133: Add sponsors
Author: login: bastnsa
Created: 2025-03-11T00:47:08Z | Merged: 2025-03-15T12:51:11Z

## what does this PR do?

- [ ] Adds a new article
- [x] Fixes a bug / Implements a feature (please describe below)

Implements a funny parody sponsorship system. 
  1. Sponsors are stored in sponsors.json with sponsor properties (title, author handle, display name, image path, and alt text).
  2. A SponsorBanner component renders sponsors in two positions - on home/listing pages and on article pages.
  3. Placement details:
    - On listing pages: Shows 2 sponsors at calculated positions (roughly in thirds)
    - On article pages: Places a sponsor banner after content
    - Uses some randomization to ensure consistent sponsor display (the more sponsors the better)
  4. Added a CLI script (new-sponsor.ts) that works very similar to (new-article.ts):
    - Collects sponsor details interactively
    - Validates and processes images
    - Updates the sponsor json file
  5. Sponsor images are stored in the public directory with standardized slug naming.

## justification

- [ ] If you are adding a new article, why is it funny? (please explain below)
- [x] If you are implementing a feature, why is it important? (please explain below)

My reasons:
    - Creates opportunity for additional jokes and commentary
    - Adds visual humor that complements the written articles
    - More variety in the type of content (quicker and easier to contribute)

## checklist

- [x] I have read the [contributing guide](https://github.com/TehcCringe/TehcCringe/blob/master/docs/contributing.md)
- [x] I have read the [rules](https://github.com/TehcCringe/TehcCringe/blob/master/docs/rules.md)
- [x] I have read the [style guide](https://github.com/TehcCringe/TehcCringe/blob/master/docs/style-guide.md)

## would your mom approve of this PR?

- [x] Yes
- [ ] No

If you selected no, please explain why



Diff:
diff --git a/app/[page]/page.tsx b/app/[page]/page.tsx
index e33c952..35da95b 100644
--- a/app/[page]/page.tsx
+++ b/app/[page]/page.tsx
@@ -4,6 +4,12 @@ import Flex from "../components/ui/flex"
 import { ArticleType, getAllArticles } from "../lib/articles"
 import { layoutComponents, layouts, LayoutType } from "../components/layouts"
 import Link from "next/link"
+import SponsorBanner from "../components/sponsor-banner"
+import { getSponsorsForPage } from "../lib/sponsors"
+import { getAllSponsors } from "../lib/sponsors"
+import { cpSync } from "fs"
+import { join } from "path"
+import { slugify } from "@/app/lib/utils"
 
 const paginationChunkAmount = 10
 
@@ -60,17 +66,55 @@ export default async function Home({ params }: { params: { page: string } }) {
     itemsUsed += layout.itemCount
   }
 
+  // Copy sponsors to public/assets at build time to ensure assets are available
+  getAllSponsors().forEach(sponsor => {
+    const slug = slugify(sponsor.title)
+    const assetDir = join(process.cwd(), "public", "assets", "sponsors", slug)
+    const sponsorDir = join(process.cwd(), "sponsors", slug)
+
+    cpSync(sponsorDir, assetDir, { recursive: true })
+  })
+
+  // Get 2 sponsors for the homepage
+  const pageKey = `page-${params.page}`
+  const sponsors = getSponsorsForPage(pageKey, 2)
+
+  // Determine sponsor insertion points (roughly divide layoutChunks into thirds)
+  const insertionPoints =
+    layoutChunks.length > 2
+      ? [
+          Math.floor(layoutChunks.length / 3),
+          Math.floor((layoutChunks.length * 2) / 3),
+        ]
+      : [0]
+
   return (
     <Flex col align="center" className="bg-mantle">
       <ArticleContainer mode="desktop">
         {layoutChunks.map(({ layout, items }, i) => {
           const Component = layoutComponents[layout.Layout]
-
-          return <Component key={i} items={items} />
+          const shouldInsertSponsor =
+            sponsors.length > 0 && insertionPoints.includes(i)
+          const sponsorIndex = insertionPoints.indexOf(i)
+
+          return (
+            <Flex key={i} col width="full">
+              <Component items={items} />
+              {shouldInsertSponsor && sponsors[sponsorIndex] && (
+                <SponsorBanner
+                  sponsor={sponsors[sponsorIndex]}
+                  position="homePage"
+                />
+              )}
+            </Flex>
+          )
         })}
       </ArticleContainer>
       <ArticleContainer mode="mobile">
         <ColumnLayout items={byNewest} />
+        {sponsors.length > 0 && (
+          <SponsorBanner sponsor={sponsors[0]} position="homePage" />
+        )}
       </ArticleContainer>
       <Flex width="full" p={2} justify="center">
         <Flex width="full" gap={2} className="max-w-[480px]">
diff --git a/app/components/sponsor-banner.tsx b/app/components/sponsor-banner.tsx
new file mode 100644
index 0000000..0ede96b
--- /dev/null
+++ b/app/components/sponsor-banner.tsx
@@ -0,0 +1,77 @@
+import Image from "next/image"
+import Flex from "./ui/flex"
+import { styled } from "react-tailwind-variants"
+import { Sponsor } from "../lib/sponsors"
+import { slugify } from "@/app/lib/utils"
+
+interface SponsorBannerProps {
+  sponsor: Sponsor
+  className?: string
+  position?: "homePage" | "articlePage"
+}
+
+export default function SponsorBanner({
+  sponsor,
+  className = "",
+  position = "homePage",
+}: SponsorBannerProps) {
+  if (!sponsor) return null
+
+  return (
+    <SponsorWrapper position={position}>
+      <BannerContainer position={position} className={className}>
+        <Flex col gap={2} width="full">
+          <div className="text-xs tracking-wider mb-2">SPONSORED</div>
+          <Flex align="center" gap={4}>
+            <div className="relative w-full h-72 mb-2">
+              <Image
+                src={`/assets/sponsors/${slugify(sponsor.title)}/image.png`}
+                alt={sponsor.alt}
+                fill
+                style={{ objectFit: "contain" }}
+                priority={false}
+              />
+            </div>
+          </Flex>
+          <div className="text-xs text-subtext0">
+            This ad is brought to you by{" "}
+            <a
+              href={sponsor.author}
+              className="hover:underline hover:text-sky transition-colors"
+            >
+              {sponsor.displayName}
+            </a>
+          </div>
+        </Flex>
+      </BannerContainer>
+    </SponsorWrapper>
+  )
+}
+
+// Outer wrapper that visually connects with articles
+const SponsorWrapper = styled("div", {
+  base: "w-full bg-base",
+  variants: {
+    position: {
+      homePage: "mx-auto border-t-2 border-x-2 border-crust",
+      articlePage: "mx-auto",
+    },
+  },
+  defaultVariants: {
+    position: "homePage",
+  },
+})
+
+// Inner container for the banner content
+const BannerContainer = styled("div", {
+  base: "w-full p-4 bg-surface0 my-6 border-x-2 border-y-2 border-surface1 mx-auto overflow-hidden",
+  variants: {
+    position: {
+      homePage: "max-w-4xl",
+      articlePage: "max-w-3xl",
+    },
+  },
+  defaultVariants: {
+    position: "homePage",
+  },
+})
diff --git a/app/lib/sponsors.ts b/app/lib/sponsors.ts
new file mode 100644
index 0000000..11fbdf7
--- /dev/null
+++ b/app/lib/sponsors.ts
@@ -0,0 +1,67 @@
+import { join } from "path"
+import { readFileSync, readdirSync } from "fs"
+import { z } from "zod"
+
+export const sponsorSchema = z.object({
+  title: z.string(),
+  author: z.string(),
+  displayName: z.string(),
+  alt: z.string(),
+})
+
+export type Sponsor = z.infer<typeof sponsorSchema>
+
+export function getAllSponsors(): Array<Sponsor> {
+  const sponsorDirs = join(process.cwd(), "sponsors")
+  const sponsorNames = readdirSync(sponsorDirs, { withFileTypes: true })
+    .filter(dir => dir.isDirectory())
+    .map(dir => dir.name)
+
+  const sponsors: Sponsor[] = []
+
+  for (const sponsorName of sponsorNames) {
+    const sponsorDataPath = join(sponsorDirs, sponsorName, "sponsor.json")
+    const sponsorData = JSON.parse(readFileSync(sponsorDataPath, "utf-8"))
+    sponsors.push(sponsorSchema.parse(sponsorData))
+  }
+
+  return sponsors
+}
+
+// Generate pseudo-random number based on seed
+export const seedRandom = (seed: string): (() => number) => {
+  let state = Array.from(seed).reduce(
+    (acc, char) => acc + char.charCodeAt(0),
+    0,
+  )
+
+  return () => {
+    state = (state * 9301 + 49297) % 233280
+    return state / 233280
+  }
+}
+
+// Get sponsors for a specific page using randomization
+export function getSponsorsForPage(
+  pageKey: string,
+  count: number = 1,
+): Array<Sponsor> {
+  const sponsors = getAllSponsors()
+
+  if (sponsors.length === 0) {
+    return []
+  }
+
+  // Use page as seed
+  const random = seedRandom(pageKey)
+  const shuffled = [...sponsors]
+
+  // Shuffle with seeded random
+  for (let i = shuffled.length - 1; i > 0; i--) {
+    const j = Math.floor(random() * (i + 1))
+    ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
+  }
+
+  // Return requested number of sponsors
+  return shuffled.slice(0, Math.min(count, shuffled.length))
+}
diff --git a/app/news/[slug]/page.tsx b/app/news/[slug]/page.tsx
index 4e08eac..3de67c2 100644
--- a/app/news/[slug]/page.tsx
+++ b/app/news/[slug]/page.tsx
@@ -11,6 +11,8 @@ import { H1 } from "@/app/components/markdown/headers"
 import { A } from "@/app/components/markdown/paragraph"
 import { Code } from "@/app/components/markdown/code"
 import { Metadata } from "next"
+import SponsorBanner from "@/app/components/sponsor-banner"
+import { getSponsorsForPage } from "@/app/lib/sponsors"
 
 export default async function Page({
   params,
@@ -28,6 +30,9 @@ export default async function Page({
   const prevArticle = allArticles[index - 1]
   const nextArticle = allArticles[index + 1]
 
+  // Get a sponsor for this article - use slug as a consistent seed
+  const sponsor = getSponsorsForPage(`article-${slug}`, 1)
+
   return (
     <Flex col p={4} align="center">
       <Flex col gap={4} align="center" className="max-w-[720px]">
@@ -98,6 +103,10 @@ export default async function Page({
           <MarkdownRenderer>{article.content}</MarkdownRenderer>
         </ArticleProvider>
 
+        {sponsor.length > 0 && (
+          <SponsorBanner sponsor={sponsor[0]} position="articlePage" />
+        )}
+
         <Flex width="full" gap={2}>
           <Flex grow noBasis asChild={prevArticle ? true : undefined} p={2}>
             {prevArticle && (
diff --git a/package.json b/package.json
index 32f051d..0f8e1aa 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
     "start": "next start",
     "lint": "next lint",
     "new:article": "bun scripts/new-article.ts",
+    "new:sponsor": "bun scripts/new-sponsor.ts",
     "format": "prettier --ignore-path .gitignore --write \"**/*.{js,ts,tsx}\"",
     "format:check": "prettier --ignore-path .gitignore --check \"**/*.{js,ts,tsx}\""
   },
diff --git a/scripts/new-sponsor.ts b/scripts/new-sponsor.ts
new file mode 100644
index 0000000..7954676
--- /dev/null
+++ b/scripts/new-sponsor.ts
@@ -0,0 +1,177 @@
+import inquirer from "inquirer"
+import { existsSync, mkdirSync, readdirSync, writeFileSync } from "fs"
+import sharp from "sharp"
+import { join } from "path"
+import chalk from "chalk"
+import type { Sponsor } from "../app/lib/sponsors"
+import { slugify } from "markdown-to-jsx"
+
+const res = await inquirer.prompt([
+  {
+    type: "input",
+    message: "Sponsor Title",
+    name: "title",
+    validate(value) {
+      if (!value.trim()) {
+        return "Title is required"
+      }
+
+      const sponsorsDir = join(process.cwd(), "sponsors")
+      const sponsorsChildren = readdirSync(sponsorsDir)
+
+      if (sponsorsChildren.includes(slugify(value))) {
+        return "Sponsor with this title already exists"
+      }
+
+      return true
+    },
+  },
+  {
+    type: "input",
+    message: "Path to sponsor image",
+    name: "imagePath",
+    validate: async value => {
+      try {
+        if (
+          !value.endsWith(".jpg") &&
+          !value.endsWith(".png") &&
+          !value.endsWith(".jpeg") &&
+          !value.endsWith(".webp")
+        ) {
+          return "Unsupported extension. Expected one of: .jpg, .png, .jpeg, .webp"
+        }
+
+        const fileExists = existsSync(value)
+
+        if (!fileExists) {
+          return "File does not exist"
+        }
+
+        const image = sharp(value)
+        const metadata = await image.metadata()
+
+        if (!metadata.width || !metadata.height) {
+          return "Image dimensions are not available"
+        }
+
+        if (metadata.width < 878 || metadata.height < 497) {
+          return "Image dimensions must be at least 878x497"
+        }
+
+        if (metadata.height > metadata.width) {
+          return "Image height must be less than or equal to image width"
+        }
+
+        return true
+      } catch (error) {
+        return (error as Error).message
+      }
+    },
+  },
+  {
+    type: "input",
+    message: "Alt text for image",
+    name: "alt",
+    validate(value) {
+      if (!value.trim()) {
+        return "Alt text is required for accessibility"
+      }
+      return true
+    },
+  },
+  {
+    type: "list",
+    message: "Choose a social platform",
+    name: "platform",
+    choices: ["Github", "𝕏 / Twitter", "Post anonymously"],
+    default: "Github",
+  },
+])
+
+let author: string | null = null
+let displayName: string | null = null
+
+if (res.platform !== "Post anonymously") {
+  author = (
+    await inquirer.prompt([
+      {
+        type: "input",
+        message: res.platform + " Handle",
+        name: "author",
+        validate(value) {
+          if (!value.match(/^@?[a-zA-Z0-9_]{2,}$/)) {
+            return `Invalid ${res.platform} handle`
+          }
+
+          return true
+        },
+      },
+    ])
+  ).author
+
+  displayName = (
+    await inquirer.prompt([
+      {
+        type: "input",
+        message: "Display Name (optional)",
+        name: "displayName",
+        default: author ?? "Anonymity",
+      },
+    ])
+  ).displayName
+}
+
+const slug = slugify(res.title)
+
+mkdirSync(join(process.cwd(), "sponsors", slug))
+
+const sponsorDir = join(process.cwd(), "sponsors", slug)
+
+const image = await sharp(res.imagePath)
+  .resize(800, null)
+  .jpeg({ mozjpeg: true })
+  .toBuffer()
+
+writeFileSync(join(sponsorDir, "image.png"), image)
+
+let handle: string | null = null
+
+if (author) {
+  const strippedHandle = author.replace(/^@/, "")
+
+  if (res.platform === "Github") {
+    handle = "https://github.com/" + strippedHandle
+  } else {
+    handle = "https://x.com/" + strippedHandle
+  }
+}
+
+const sponsorData: Sponsor = {
+  title: res.title,
+  author: "",
+  displayName: "",
+  alt: res.alt,
+}
+
+if (handle) {
+  sponsorData.author = handle
+}
+
+if (displayName) {
+  sponsorData.displayName = displayName
+}
+
+writeFileSync(
+  join(sponsorDir, "sponsor.json"),
+  JSON.stringify(sponsorData, null, 2),
+  "utf8",
+)
+
+console.log(
+  chalk.green("New sponsor added successfully:"),
+  chalk.cyan(res.title),
+)
+console.log(
+  chalk.green("Sponsor directory created at:"),
+  chalk.cyan(`sponsors/${slug}`),
+)
diff --git a/sponsors/ai-pencil/image.png b/sponsors/ai-pencil/image.png
new file mode 100644
index 0000000..5823d47
Binary files /dev/null and b/sponsors/ai-pencil/image.png differ
diff --git a/sponsors/ai-pencil/sponsor.json b/sponsors/ai-pencil/sponsor.json
new file mode 100644
index 0000000..8486081
--- /dev/null
+++ b/sponsors/ai-pencil/sponsor.json
@@ -0,0 +1,6 @@
+{
+  "title": "AI Pencil",
+  "author": "https://x.com/bastnsa",
+  "displayName": "Bastian",
+  "alt": "Worlds first AI Pencil"
+}
diff --git a/sponsors/blockchain-toaster/image.png b/sponsors/blockchain-toaster/image.png
new file mode 100644
index 0000000..d46dca7
Binary files /dev/null and b/sponsors/blockchain-toaster/image.png differ
diff --git a/sponsors/blockchain-toaster/sponsor.json b/sponsors/blockchain-toaster/sponsor.json
new file mode 100644
index 0000000..725b1b0
--- /dev/null
+++ b/sponsors/blockchain-toaster/sponsor.json
@@ -0,0 +1,6 @@
+{
+  "title": "Blockchain Toaster",
+  "author": "https://x.com/bastnsa",
+  "displayName": "Bastian",
+  "alt": "Toast your bread on the blockchain"
+}
\ No newline at end of file
diff --git a/sponsors/gitrub/image.png b/sponsors/gitrub/image.png
new file mode 100644
index 0000000..5487d8d
Binary files /dev/null and b/sponsors/gitrub/image.png differ
diff --git a/sponsors/gitrub/sponsor.json b/sponsors/gitrub/sponsor.json
new file mode 100644
index 0000000..ca198fc
--- /dev/null
+++ b/sponsors/gitrub/sponsor.json
@@ -0,0 +1,6 @@
+{
+  "title": "GitRub",
+  "author": "https://x.com/bastnsa",
+  "displayName": "Bastian",
+  "alt": "Version control for your back pain"
+}
diff --git a/sponsors/quantum-brew/image.png b/sponsors/quantum-brew/image.png
new file mode 100644
index 0000000..0d0cc59
Binary files /dev/null and b/sponsors/quantum-brew/image.png differ
diff --git a/sponsors/quantum-brew/sponsor.json b/sponsors/quantum-brew/sponsor.json
new file mode 100644
index 0000000..d04961e
--- /dev/null
+++ b/sponsors/quantum-brew/sponsor.json
@@ -0,0 +1,6 @@
+{
+  "title": "Quantum Brew",
+  "author": "https://x.com/bastnsa",
+  "displayName": "Bastian",
+  "alt": "First coffee brewing system based on quantum principles"
+}


Reviews:
user: login: IroncladDev
body: Overall great PR. Requested some changes. Ping me once you get those done and I'll take another pass.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T19:57:49Z
user: login: bastnsa
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-12T10:50:42Z
user: login: IroncladDev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T17:39:44Z
user: login: IroncladDev
body: Requested some final changes. After that, should be good to merge!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T12:46:56Z

Review comments:
diff_hunk: @@ -1,6 +1,7 @@
 {
   "extends": ["next/core-web-vitals", "next/typescript"],
   "rules": {
-    "@next/next/no-img-element": "off"
+    "@next/next/no-img-element": "off",
+    "@typescript-eslint/no-require-imports": "off"
path: .eslintrc.json
user: login: IroncladDev
body: You shouldn't need to use `require` in an ESM environment unless you're trying to work around a circular dependency, which is extremely rare
created_at: 2025-03-11T19:46:08Z
author_association: CONTRIBUTOR
path: app/components/sponsor-banner.tsx
user: login: IroncladDev
body: A couple of nits on code style: 

1. I prefer using `export default function SponsorBanner` instead of `const SponsorBanner`
2. To make it easier to edit the file, please add the styled tailwind components **after** the main component near the end of the file
created_at: 2025-03-11T19:48:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -5,3 +5,39 @@ export const slugify = (str: string) => {
     .replace(/\s+/g, "-")
     .trim()
 }
+
+// Generate pseudo-random number based on seed
+export const seedRandom = (seed: string): (() => number) => {
+  let state = Array.from(seed).reduce(
+    (acc, char) => acc + char.charCodeAt(0),
+    0,
+  )
+
+  return () => {
+    state = (state * 9301 + 49297) % 233280
+    return state / 233280
+  }
+}
+
+// Get sponsors for a specific page using randomization
+export const getSponsorsForPage = (pageKey: string, count: number = 1) => {
+  const sponsorsData = require("../../sponsors/sponsors.json")
path: app/lib/utils.ts
user: login: IroncladDev
body: Use `fs` or a dynamic import instead of `require` for this
created_at: 2025-03-11T19:48:51Z
author_association: CONTRIBUTOR
path: sponsors/sponsors.d.ts
user: login: IroncladDev
body: Please use `zod` to create a schema so the value of a `Sponsor` can be validated at runtime.

A `.d.ts` file isn't necessary. You can just use `.ts`

See `app/utils/articles.ts` for how I did this.
created_at: 2025-03-11T19:53:16Z
author_association: CONTRIBUTOR
path: sponsors/sponsors.json
user: login: IroncladDev
body: I would prefer to use file-based routing sort of how the `articles` directory works with each sponsor having its own directory with an image and a markdown/json file for info.
created_at: 2025-03-11T19:54:09Z
author_association: CONTRIBUTOR
path: sponsors/sponsors.json
user: login: bastnsa
body: I got everything pretty much squared away besides a couple of questions: would you like me to copy sponsor assets at built time into `public/assets/` as well? And if so, do you have a preferred sub directory to keep them separate from articles? Maybe something like `public/assets/sponsors/`?
created_at: 2025-03-12T10:50:41Z
author_association: NONE
path: sponsors/sponsors.json
user: login: IroncladDev
body: Yeah go ahead and copy the assets into `public/assets/` just like how articles do it, at build time
created_at: 2025-03-12T17:39:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +155,16 @@ export async function generateStaticParams() {
     chunks.push({ page: i.toString() })
   }
 
+  const sponsors = getAllSponsors()
path: app/[page]/page.tsx
user: login: IroncladDev
body: copying the sponsor assets into the `assets` directory has nothing to do with generating static params. The `Home` function is a server component generated at build time so you can move this logic into there.
created_at: 2025-03-13T12:38:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,7 @@
+{
+  "title": "AI Pencil",
+  "author": "https://x.com/bastnsa",
+  "displayName": "Bastian",
+  "image": "/assets/sponsors/ai-pencil/image.jpg",
path: sponsors/ai-pencil/sponsor.json
user: login: IroncladDev
body: We don't need the `image` field for the sponsor since each sponsor in the `sponsors` directory has an image file
created_at: 2025-03-13T12:43:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -127,14 +121,18 @@ if (res.platform !== "Post anonymously") {
   ).displayName
 }
 
-// const imageFileName = `${res.title.toLowerCase().replace(/\s+/g, "")}.jpg`
-const slug = slugify(res.title) + ".jpg"
+const slug = slugify(res.title)
+
+mkdirSync(join(process.cwd(), "sponsors", slug))
+
+const sponsorDir = join(process.cwd(), "sponsors", slug)
+
 const image = await sharp(res.imagePath)
   .resize(800, null)
   .jpeg({ mozjpeg: true })
   .toBuffer()
 
-writeFileSync(join(sponsorsImagesDir, slug), image)
+writeFileSync(join(sponsorDir, "image.jpg"), image)
path: scripts/new-sponsor.ts
user: login: IroncladDev
body: PNGs are preferred. Use `writeFileSync(join(sponsorDir, "image.png"), image)`

Even though `sharp` is using the `.jpeg({ mozjpeg: true })` method, it still works fine (same logic in new-article.ts)

After this PR is merged I'll take a look and maybe fix up the method if it doesn't work.
created_at: 2025-03-13T12:46:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: bastnsa
created_at: 2025-03-12T21:26:38Z
body: > Overall great PR. Requested some changes. Ping me once you get those done and I'll take another pass.

Thanks for the feedback! I've addressed all the comments
author_association: NONE
user: login: bastnsa
created_at: 2025-03-13T23:12:00Z
body: > Requested some final changes. After that, should be good to merge!

Once again, I appreciate the detailed feedback, thanks!
author_association: NONE