Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/blog-posthog-tiles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Blog PostHog Tiles

# Re-bakes the static "Blog posts published per month" and "Blog library age"
# PostHog tiles from frontmatter publish dates whenever blog content changes,
# so they stay current with no manual refresh. See
# apps/blog/scripts/update-posthog-blog-tiles.mjs.

on:
workflow_dispatch:
push:
branches: [main]
paths: [apps/blog/content/blog/**/*.mdx]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions: {}

jobs:
update-tiles:
name: Update PostHog blog tiles
timeout-minutes: 10
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Re-bake PostHog tiles from frontmatter
run: node apps/blog/scripts/update-posthog-blog-tiles.mjs
env:
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
175 changes: 175 additions & 0 deletions apps/blog/scripts/update-posthog-blog-tiles.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Re-bakes the static PostHog blog tiles from blog frontmatter so they stay
// current with no manual refresh.
//
// Tiles updated (PostHog project 60295, dashboard "Blog SEO & Performance"):
// - "Blog posts published per month" (insight 9555387 / 1eGn424G)
// - "Blog library age — older vs younger 12mo" (insight 9555402 / IasjsfXT)
// - "Recent posts ranked by pageviews" (insight 9497434 / D2eDZ3eq)
//
// Run by .github/workflows/blog-posthog-tiles.yml on every push to main that
// touches apps/blog/content/blog/**. The first two tiles are driven by a
// publish-month histogram (frontmatter `date`); the recent-posts tile is driven
// by the list of posts published OR updated in the last 30 days (frontmatter
// `date` / `updatedAt`).
//
// Local dry run (prints the queries, no API call):
// node apps/blog/scripts/update-posthog-blog-tiles.mjs --dry-run

import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const PROJECT_ID = 60295;
const HOST = "https://us.posthog.com";

// Numeric insight ids (short ids in comments) on the Blog SEO & Performance dashboard.
const INSIGHTS = {
postsPerMonth: 9555387, // 1eGn424G
libraryAge: 9555402, // IasjsfXT
recentPosts: 9497434, // D2eDZ3eq
};

const RECENT_DAYS = 30;

const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "content", "blog");

/** Read the frontmatter block of every post: { slug, date, updatedAt }. */
function readPosts() {
const posts = [];
for (const entry of readdirSync(CONTENT_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
let raw;
try {
raw = readFileSync(join(CONTENT_DIR, entry.name, "index.mdx"), "utf8");
} catch {
continue; // directory without an index.mdx
}
const fm = raw.startsWith("---") ? (raw.split(/^---\s*$/m)[1] ?? "") : raw;
const date = (fm.match(/^date:\s*["']?(\d{4}-\d{2}-\d{2})/m) ?? [])[1] ?? null;
const updatedAt = (fm.match(/^updatedAt:\s*["']?(\d{4}-\d{2}-\d{2})/m) ?? [])[1] ?? null;
posts.push({ slug: entry.name, date, updatedAt });
}
return posts;
}

/** Sorted [ ['YYYY-MM', count], ... ] histogram of publish months. */
function publishHistogram(posts) {
const counts = new Map();
for (const { date } of posts) {
if (!date) continue;
const month = date.slice(0, 7);
counts.set(month, (counts.get(month) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1));
}

/** Slugs of posts published OR updated within the last RECENT_DAYS days. */
function recentSlugs(posts) {
const cutoff = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString().slice(0, 10);
return posts
.filter((p) => (p.date && p.date > cutoff) || (p.updatedAt && p.updatedAt > cutoff))
.map((p) => p.slug)
.sort();
}

function tuplesLiteral(hist) {
return hist.map(([month, count]) => `tuple('${month}',${count})`).join(",");
}

function postsPerMonthQuery(tuples) {
return {
kind: "DataVisualizationNode",
display: "ActionsBar",
source: {
kind: "HogQLQuery",
query: `SELECT toDate(concat(t.1, '-01')) AS month, t.2 AS posts FROM (SELECT arrayJoin([${tuples}]) AS t) ORDER BY month`,
},
chartSettings: {
xAxis: { column: "month" },
yAxis: [{ column: "posts", settings: { display: { label: "Posts published" } } }],
},
};
}

function libraryAgeQuery(tuples) {
const query =
`WITH hist AS (SELECT toDate(concat(t.1, '-01')) AS pub, t.2 AS cnt FROM (SELECT arrayJoin([${tuples}]) AS t)) ` +
`SELECT M AS month, ` +
`sumIf(cnt, pub > M - toIntervalMonth(12) AND pub <= M) AS younger_than_12mo, ` +
`sumIf(cnt, pub <= M - toIntervalMonth(12)) AS older_than_12mo ` +
`FROM (SELECT arrayJoin(arrayMap(i -> toStartOfMonth(today()) - toIntervalMonth(i), range(0, 36))) AS M) months ` +
`CROSS JOIN hist GROUP BY M ORDER BY M`;
return {
kind: "DataVisualizationNode",
display: "ActionsStackedBar",
source: { kind: "HogQLQuery", query },
chartSettings: {
xAxis: { column: "month" },
yAxis: [
{ column: "younger_than_12mo", settings: { display: { label: "< 12 months" } } },
{ column: "older_than_12mo", settings: { display: { label: "> 12 months" } } },
],
},
};
}

function recentPostsQuery(slugs) {
const quote = (s) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
const arr = slugs.length ? `[${slugs.map(quote).join(",")}]` : "CAST([] AS Array(String))";
const query =
`WITH recent AS (SELECT arrayJoin(${arr}) AS slug), ` +
`pv AS (SELECT properties.$pathname AS path, count() AS pageviews, count(DISTINCT person_id) AS visitors ` +
`FROM events WHERE event = '$pageview' AND properties.$host = 'www.prisma.io' ` +
`AND timestamp >= now() - INTERVAL 30 DAY AND properties.$pathname LIKE '/blog/%' GROUP BY path) ` +
`SELECT path, pageviews, visitors, rank, multiIf(rank <= 3, 'Top 3', rank > total - 3, 'Bottom 3', '') AS tier ` +
`FROM (SELECT concat('/blog/', recent.slug) AS path, coalesce(pv.pageviews, 0) AS pageviews, ` +
`coalesce(pv.visitors, 0) AS visitors, row_number() OVER (ORDER BY coalesce(pv.pageviews, 0) DESC) AS rank, ` +
`count() OVER () AS total FROM recent LEFT JOIN pv ON pv.path = concat('/blog/', recent.slug)) ` +
`ORDER BY rank LIMIT 100`;
return { kind: "DataVisualizationNode", display: "ActionsTable", source: { kind: "HogQLQuery", query } };
}

async function patchInsight(id, query) {
const res = await fetch(`${HOST}/api/projects/${PROJECT_ID}/insights/${id}/`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.POSTHOG_API_KEY}`,
},
body: JSON.stringify({ query }),
});
if (!res.ok) {
throw new Error(`PATCH insight ${id} failed: ${res.status} ${await res.text()}`);
}
}

const posts = readPosts();
const hist = publishHistogram(posts);
const tuples = tuplesLiteral(hist);
const total = hist.reduce((sum, [, count]) => sum + count, 0);
const recent = recentSlugs(posts);
console.log(
`Blog posts: ${total} across ${hist.length} months (${hist[0]?.[0]} – ${hist.at(-1)?.[0]}); ${recent.length} recent (≤${RECENT_DAYS}d).`,
);

if (process.argv.includes("--dry-run")) {
console.log("\nDRY RUN — queries that would be written (no API call):\n");
console.log("posts-per-month:\n" + postsPerMonthQuery(tuples).source.query + "\n");
console.log("library-age:\n" + libraryAgeQuery(tuples).source.query + "\n");
console.log("recent slugs (" + recent.length + "): " + recent.join(", ") + "\n");
console.log("recent-posts:\n" + recentPostsQuery(recent).source.query);
process.exit(0);
}

if (!process.env.POSTHOG_API_KEY) {
console.error("Error: POSTHOG_API_KEY is not set. Set the repo secret, or pass --dry-run to preview.");
process.exit(1);
}

await patchInsight(INSIGHTS.postsPerMonth, postsPerMonthQuery(tuples));
console.log(`Updated posts-per-month insight (${INSIGHTS.postsPerMonth}).`);
await patchInsight(INSIGHTS.libraryAge, libraryAgeQuery(tuples));
console.log(`Updated library-age insight (${INSIGHTS.libraryAge}).`);
await patchInsight(INSIGHTS.recentPosts, recentPostsQuery(recent));
console.log(`Updated recent-posts insight (${INSIGHTS.recentPosts}).`);
console.log("Done.");
Loading