-
Notifications
You must be signed in to change notification settings - Fork 0
fix(seo): og:image jpg not webp so LinkedIn renders social previews #452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
57c0a7d
aecd55e
4ac97a3
8f83faa
2367b7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -107,41 +107,52 @@ Every line of every LinkedIn post must pass: | |||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| --- | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ## Post shape: story, not advice (BLOCKING) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| The single biggest tell that a post is sales/marketing is its **shape**, not its words. Marketing posts follow a predictable arc: hook → anecdote → generalization → solution → objection-handling → empowerment → CTA. That shape itself reads as marketing even when every individual word is fine. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Paul's posts must be **story-shaped**, not advice-shaped: | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| | ❌ Advice shape (marketing) | ✅ Story shape (conversation) | | ||||||||||||||||||||||||||||
| ## Post shape: idea-first, deliver the point (BLOCKING) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| > **Doctrine corrected 2026-08-13.** The prior rule ("story, not advice" - force | ||||||||||||||||||||||||||||
| > every post into a single-encounter parable) *generated* the AI slop Paul rejected: | ||||||||||||||||||||||||||||
| > staged "a founder pinged me last Tuesday" openers, the tactic buried as the | ||||||||||||||||||||||||||||
| > founder's future action (zero takeaway), and "So... So..." connectors. It is | ||||||||||||||||||||||||||||
| > replaced below. Reference writers Paul rates: **John Cutler** (`johnpcutler`) and | ||||||||||||||||||||||||||||
| > **Luca Rossi** (`lucaronin`, refactoring.fm) - study their recent activity. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| The marketing tell is the **arc**, not advice itself. Sales posts run hook → anecdote → generalization → solution → objection-handling → empowerment → CTA. Drop the arc - do NOT hide the point inside a parable. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **Write idea-first, like Cutler and Rossi:** | ||||||||||||||||||||||||||||
| - **Open with the idea, flat.** A belief, observation, or claim - not a staged encounter. Rossi: *"One of the dominant narratives in software today is that AI is making junior engineers redundant."* Cutler: *"'We want visibility' is stated as a universal need. But..."* | ||||||||||||||||||||||||||||
| - **Argue it plainly, then take a position.** Rossi: *"It goes like this... I believe this is a mistake."* | ||||||||||||||||||||||||||||
| - **Deliver the tactic in full.** Advice IS the value - give the actual move away. (Opposite of the old "hide the tactic inside what the founder will do next" rule.) | ||||||||||||||||||||||||||||
| - **Plain, short sentences.** No stacked sub-clauses. Paul: "I write simpler." | ||||||||||||||||||||||||||||
| - **Paragraph breaks are fine.** Cutler and Rossi use clear one-idea paragraphs. The old "don't separate the beats, add So-connectors" rule is **REVOKED** - it manufactured the beat-marking Paul flags as slop. | ||||||||||||||||||||||||||||
| - **Close with a real peer question.** | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| | ❌ Old parable shape (now banned) | ✅ Idea-first shape | | ||||||||||||||||||||||||||||
| |---|---| | ||||||||||||||||||||||||||||
| | Open with a tagline ("Jira is not progress.") | Open with a specific recent encounter ("Founder showed me her sprint board last week.") | | ||||||||||||||||||||||||||||
| | Generalize the pattern in the middle ("This shows up in almost every team I look at...") | Stay inside the encounter, let the reader generalize | | ||||||||||||||||||||||||||||
| | Lift the tactical advice into a how-to section ("What works is asking for a URL...") | Embed the tactical move inside what the founder is going to do next ("She's going to try something this Friday: ...") | | ||||||||||||||||||||||||||||
| | Add an objection-handling bullet list ("- We're refactoring → ...") | Skip the objection list. If the post must address objections, fold them into one sentence inside the story | | ||||||||||||||||||||||||||||
| | Close with empowerment + question ("Any non-technical founder can do this. What's your story?") | Close with a single peer question ("Anyone else been in this version of it?") | | ||||||||||||||||||||||||||||
| | Staged encounter opener ("A founder pinged me last Tuesday...") | The idea, flat ("A lot of non-technical founders read a full board as proof of progress.") | | ||||||||||||||||||||||||||||
| | Bloat the encounter into a 4-paragraph screenplay with "So... So..." connectors | Argue the pattern in plain sentences | | ||||||||||||||||||||||||||||
| | Bury the tactic as the founder's future action; deliver no advice | Deliver the tactic directly - reader can use it today | | ||||||||||||||||||||||||||||
| | Manufactured specificity (14 tickets, since January, this Friday, ten minutes) | One real detail if you have it, else none - don't fabricate a tidy composite | | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **The shape test:** Read the post in your head. If it could appear unchanged in a "5 ways to spot a stalled dev team" newsletter, the shape is wrong. Rewrite as a recounted encounter, not as advice with a story decorating it. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Story-shape skeleton | ||||||||||||||||||||||||||||
| ### Idea-first skeleton | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||
| Beat 1 (1-2 lines): Specific recent encounter - concrete detail (number, day, role) | ||||||||||||||||||||||||||||
| Beat 2 (1-2 lines): The reveal moment in the encounter | ||||||||||||||||||||||||||||
| Beat 3 (1-2 lines): One line of opinion as observation, not slogan | ||||||||||||||||||||||||||||
| Beat 4 (1-2 lines): What the person in the story is going to do next | ||||||||||||||||||||||||||||
| Close (1 line): Peer question - "anyone else been in this version of it?" | ||||||||||||||||||||||||||||
| Beat 1 (1 line): The idea / belief / observation, flat - no staged encounter, no credential | ||||||||||||||||||||||||||||
| Beat 2 (1-2 lines): Restate it plainly, or show why it looks reasonable | ||||||||||||||||||||||||||||
| Beat 3 (1-2 lines): Your position, flat | ||||||||||||||||||||||||||||
| Beat 4 (3-5 lines): The tactic, delivered in full - the actual move the reader can make | ||||||||||||||||||||||||||||
| Beat 5 (1 line): Why it matters | ||||||||||||||||||||||||||||
| Close (1 line): Real peer question | ||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||
|
Comment on lines
138
to
145
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Set a language for this fenced block. Line 138 has a fenced block without a language. This triggers MD040. Proposed fix-```
+```text
Beat 1 (1 line): The idea / belief / observation, flat - no staged encounter, no credential📝 Committable suggestion
Suggested change
🧰 Tools🪛 markdownlint-cli2 (0.23.2)[warning] 138-138: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Length target: **120-180 words**. Story shape needs less transition scaffolding than the marketing arc, so posts run shorter. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### First-draft warning: don't separate the beats into paragraphs | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| The skeleton names 5 beats. The first-draft trap is to render each beat as its own paragraph with no connector to the prior beat. That produces a list-of-points shape that reads as outline, not story — the post will fail the read-aloud fluency test even if every individual sentence is clean. | ||||||||||||||||||||||||||||
| Length target: **120-160 words**. Plain sentences, one idea per paragraph. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **Fix at the writing stage:** drop in connectors at the seams as you draft. "So we hopped on a quick call." "And the thing is..." "Anyway, we agreed she'd try..." Real spoken stories don't pause for breath at every clean beat. Two paragraphs of flowing prose almost always beat 5 paragraphs of separated beats. | ||||||||||||||||||||||||||||
| ### New slop tells (2026-08-13 - caught on the jira-not-progress + validate-before-build drafts) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| **The Tuesday post lesson:** an initial 6-paragraph draft (one paragraph per beat, no connectors) was rewritten to 3 paragraphs by adding `so we hopped on a quick call`, `So we agreed she'd try a different one`, and `Beats three more sprints of...` — connectors that carry the listener forward. This is a writing-stage rule, not just an editing-stage fix. | ||||||||||||||||||||||||||||
| - **Manufactured single-encounter parable** - a too-clean composite founder story built to illustrate a lesson. | ||||||||||||||||||||||||||||
| - **"So... So... So..." beat-marking** / cinematic connectors ("So we hopped on a call") - screenplay reconstruction. (The old doctrine *mandated* these.) | ||||||||||||||||||||||||||||
| - **Cost-stacked time-cut** - "six months and a big invoice later, you still..." - manufactured drama. | ||||||||||||||||||||||||||||
| - **Stacked-clause long sentences** - one 30-word sentence with 3 sub-clauses. Break it up. | ||||||||||||||||||||||||||||
| - **Engagement-bait close as the only payload** ("Anyone else been in this version of it?") when the body delivered no value. The question is fine *after* real value; not as a substitute for it. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| ### Hook archetype rotation (Beat 1) | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| # LinkedIn Operating System — Paul Keen / JetThoughts | ||
|
|
||
| How we run Paul's LinkedIn: two lanes, a slow cadence, one voice doctrine, and | ||
| per-post analytics tracking. Nothing publishes without Paul. | ||
|
|
||
| ## Voice + shape (the engine) | ||
|
|
||
| **Canonical:** `docs/workflows/linkedin-post-pipeline.md` → "Post shape: idea-first, | ||
| deliver the point (BLOCKING)". Corrected 2026-08-13 away from the old "story, not | ||
| advice" parable doctrine that produced AI slop. In one line: **open with the idea | ||
| flat, argue it plainly, deliver the tactic in full, plain short sentences, close | ||
| with a real peer question.** Study the writers Paul rates: John Cutler | ||
| (`johnpcutler`), Luca Rossi (`lucaronin`/refactoring.fm). Good examples transcribed | ||
| in [`reference-examples.md`](reference-examples.md). | ||
|
|
||
| ## The two lanes | ||
|
|
||
| | Lane | Folder | Stage | ICP | Plan | | ||
| |---|---|---|---|---| | ||
| | **Course** (pre-validation) | `course-promo/` | idea-stage / about-to-build | validate demand *before* building | `docs/workflows/linkedin-course-promo-plan.md` | | ||
| | **Rescue** (control-loss) | `icp-validation/` | already building, can't verify | founder stuck with a dev shop | `docs/workflows/linkedin-icp-validation-plan.md` | | ||
|
|
||
| Both post from Paul's personal account. **Rotate between lanes** so the feed isn't | ||
| all one stage. Assets (images) live in each lane's `assets/` folder. | ||
|
|
||
| ## Cadence (2026-08-13, Paul) | ||
|
|
||
| **2-3 posts per week, max** — the best-for-ICP volume (non-technical founders don't | ||
| want a daily firehose). Post at the ICP's peak read time: **Tue-Thu, US-morning | ||
| (9-11am ET = ~15:00-17:00 CEST)**. Schedule via LinkedIn's native scheduler. One | ||
| lane per post, alternating. | ||
|
|
||
| ## Every post carries a visual (Paul: "missed images") | ||
|
|
||
| Each post gets one image in its lane's `assets/`. Reuse course exhibits (the | ||
| refactoring.fm-style SVGs) exported to PNG via `rsvg-convert -w 1080 <svg> -o <png>` | ||
| — they're on-brand and seed the course bridge without a link. Frontmatter records | ||
| the path. | ||
|
|
||
| > **Known tooling gap:** claude-in-chrome can schedule text but cannot drive | ||
| > LinkedIn's native file-upload dialog. Until solved, the image is attached | ||
| > manually by Paul (or dropped in a fresh composer). Track the fix in the backlog. | ||
|
|
||
| ## Frontmatter schema v2 | ||
|
|
||
| Lifecycle + pointers in frontmatter; **performance data lives in the ledger, not | ||
| the frontmatter** (single source, no double-authoring — see below). | ||
|
|
||
| ```yaml | ||
| --- | ||
| lane: course | rescue | ||
| week: 1 | ||
| day: thursday | ||
| author: paul-keen | ||
| pillar: <e.g. demand-before-build | progress-visibility> | ||
| hypothesis: <which validation hypothesis> | ||
| opener_archetype: idea-led | observation-led | question-led | stat-led | conflict-led | ||
| icp_test: <one line - what this post tests> | ||
| visual: assets/<slug>.png # required - the image that ships with the post | ||
| status: draft | scheduled | posted | ||
| scheduled_for: <ISO date, if scheduled> | ||
| posted_url: <LinkedIn URL, once posted> | ||
| first_comment: | # posted as the FIRST COMMENT right after publish (not the body) | ||
| <course lane: the ready comment incl. the UTM'd course link. rescue lane: usually empty.> | ||
| notes: | <voice trade-offs, revision history> | ||
| --- | ||
| ``` | ||
|
|
||
| ## Link policy per lane (BLOCKING) | ||
|
|
||
| - **Never a link in the post body** - LinkedIn throttles reach on external links, and JT voice bans in-body CTAs. | ||
| - **Course lane** → one UTM'd course link in the **first comment** (`first_comment` in frontmatter). Link the specific lesson that delivers on the post's promise. This is the arrival signal the metrics-ledger tracks. | ||
| - **Rescue lane** → no link. Reply-CTA only (test ICP presence via replies, not clicks). | ||
| - Hashtags: 2-3 max, relevant, at the end of the body. 0 is acceptable and on-trend. Never a wall of tags. | ||
|
|
||
| **Posting the first comment:** claude-in-chrome CAN do this (typing a comment needs no file dialog) - after the post publishes, add the `first_comment` text via the assistant. Only the image attach is a manual gap. | ||
|
|
||
| ## Analytics tracking — the ledger (proposed "better way") | ||
|
|
||
| Instead of scattering metrics across 20 frontmatter blocks, **one reviewable table**: | ||
| [`metrics-ledger.md`](metrics-ledger.md). One row per posted post, filled from | ||
| LinkedIn analytics. This is the weekly review surface (same pattern as the outreach | ||
| `pipeline.md`). Frontmatter only carries `posted_url` + `status`; the ledger owns | ||
| the numbers. | ||
|
|
||
| **Review workflow (per post):** | ||
| 1. **~48-72h after posting**, open the post's LinkedIn analytics ("View analytics"). | ||
| 2. Log the row: impressions, reactions, comments, reposts, profile views, and the | ||
| real signal — **ICP replies** (comments/DMs in ICP symptom-language). | ||
| 3. **Weekly (Fri):** scan the ledger. Which opener archetype / lane / topic pulled | ||
| ICP replies? Reuse the winners; retire the flat ones. Note it in the ledger's | ||
| "what to reuse" column. | ||
| 4. The `icp_replies` count, not impressions, decides whether the campaign is | ||
| validating (per each lane's plan kill-criteria). | ||
|
|
||
| ## Posting workflow | ||
|
|
||
| 1. Draft against the idea-first skeleton; self-score ≤2/10 (pipeline rubric) + the | ||
| shape-tell critic. | ||
| 2. Export the visual to `assets/<slug>.png`. **Attach the image at compose time, | ||
| BEFORE scheduling** - you cannot add an image to an already-published post | ||
| (LinkedIn allows text edits only). Scheduling text-first to "add the image | ||
| later" is how the 2026-08-13 post shipped imageless. (Until claude-in-chrome can | ||
| drive the native upload dialog, Paul attaches the PNG in the composer.) | ||
| 3. Stage the `first_comment` (course lane: UTM'd course link). | ||
| 4. **Schedule at least 24 HOURS ahead** (Paul rule 2026-08-13) - never same-day or | ||
| imminent. The 24h gap is Paul's **pre-verify window**: he reviews the queued post | ||
| (text, image, shape) before it goes live and can tweak or pull it. Pick the next | ||
| Tue-Thu US-morning slot that is ≥24h out. | ||
| 5. **Paul pre-verifies** within the window. Only then does it publish. | ||
| 6. **After it publishes, the assistant posts the `first_comment`** via claude-in-chrome | ||
| (typing a comment needs no file dialog - this IS automatable). Course link lives | ||
| here, not the body. | ||
| 7. Reply to ICP comments within ~2h; route real conversations to DM. | ||
| 8. Log analytics 48-72h later in the ledger. | ||
|
|
||
| ### Link unfurl (why the course link shows no preview card) | ||
|
|
||
| - **Comment links never unfurl on LinkedIn** - only body links generate a preview | ||
| card. A first-comment link is a bare clickable link by design (the cost of keeping | ||
| the link out of the reach-throttled body). | ||
| - Separately, our `og:image` is **WebP**. **Telegram unfurls it fine** (confirmed by | ||
| Paul 2026-08-13) - WebP-friendly crawlers are OK. **LinkedIn is the holdout**: its | ||
| crawler won't render WebP, so a LinkedIn *body* link shows no preview card. The fix | ||
| is LinkedIn-targeted: emit a jpg/png og:image in | ||
| `themes/beaver/layouts/partials/seo/enhanced-meta-tags.html` (or the root override). | ||
| Not a sitewide emergency (Telegram/most work); worth doing since LinkedIn is a key | ||
| channel. Tracked as an engineering task. | ||
|
|
||
| ## Files | ||
|
|
||
| - `content-plan.md` — rolling 2-3/wk calendar, lane rotation, revision-wave queue. | ||
| - `reference-examples.md` — transcribed good posts to emulate (add more over time). | ||
| - `metrics-ledger.md` — per-post performance, the weekly review surface. | ||
| - `course-promo/` , `icp-validation/` — the two lanes, each with `README.md`, drafts, `assets/`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the tracker timestamp.
Line 13 records a
2026-08-13update, but the header still saysLast Updated: 2026-08-08. This makes the live queue appear stale.Update the header timestamp in the same surgical Markdown change.
Afterward, grep for
2026-08-08to confirm the old timestamp is gone.As per coding guidelines, change only sentences containing the flagged
attribute, preserve the page thesis, avoid expanding scope, and grep the
replacement text for the removed defect before handback.
The supplied tracker context shows the newer dated update beside the older
header timestamp.
🤖 Prompt for AI Agents
Source: Coding guidelines