|
| 1 | +// Re-bakes the static PostHog blog tiles from blog frontmatter so they stay |
| 2 | +// current with no manual refresh. |
| 3 | +// |
| 4 | +// Tiles updated (PostHog project 60295, dashboard "Blog SEO & Performance"): |
| 5 | +// - "Blog posts published per month" (insight 9555387 / 1eGn424G) |
| 6 | +// - "Blog library age — older vs younger 12mo" (insight 9555402 / IasjsfXT) |
| 7 | +// - "Recent posts ranked by pageviews" (insight 9497434 / D2eDZ3eq) |
| 8 | +// |
| 9 | +// Run by .github/workflows/blog-posthog-tiles.yml on every push to main that |
| 10 | +// touches apps/blog/content/blog/**. The first two tiles are driven by a |
| 11 | +// publish-month histogram (frontmatter `date`); the recent-posts tile is driven |
| 12 | +// by the list of posts published OR updated in the last 30 days (frontmatter |
| 13 | +// `date` / `updatedAt`). |
| 14 | +// |
| 15 | +// Local dry run (prints the queries, no API call): |
| 16 | +// node apps/blog/scripts/update-posthog-blog-tiles.mjs --dry-run |
| 17 | + |
| 18 | +import { readFileSync, readdirSync } from "node:fs"; |
| 19 | +import { fileURLToPath } from "node:url"; |
| 20 | +import { dirname, join } from "node:path"; |
| 21 | + |
| 22 | +const PROJECT_ID = 60295; |
| 23 | +const HOST = "https://us.posthog.com"; |
| 24 | + |
| 25 | +// Numeric insight ids (short ids in comments) on the Blog SEO & Performance dashboard. |
| 26 | +const INSIGHTS = { |
| 27 | + postsPerMonth: 9555387, // 1eGn424G |
| 28 | + libraryAge: 9555402, // IasjsfXT |
| 29 | + recentPosts: 9497434, // D2eDZ3eq |
| 30 | +}; |
| 31 | + |
| 32 | +const RECENT_DAYS = 30; |
| 33 | + |
| 34 | +const CONTENT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "content", "blog"); |
| 35 | + |
| 36 | +/** Read the frontmatter block of every post: { slug, date, updatedAt }. */ |
| 37 | +function readPosts() { |
| 38 | + const posts = []; |
| 39 | + for (const entry of readdirSync(CONTENT_DIR, { withFileTypes: true })) { |
| 40 | + if (!entry.isDirectory()) continue; |
| 41 | + let raw; |
| 42 | + try { |
| 43 | + raw = readFileSync(join(CONTENT_DIR, entry.name, "index.mdx"), "utf8"); |
| 44 | + } catch { |
| 45 | + continue; // directory without an index.mdx |
| 46 | + } |
| 47 | + const fm = raw.startsWith("---") ? (raw.split(/^---\s*$/m)[1] ?? "") : raw; |
| 48 | + const date = (fm.match(/^date:\s*["']?(\d{4}-\d{2}-\d{2})/m) ?? [])[1] ?? null; |
| 49 | + const updatedAt = (fm.match(/^updatedAt:\s*["']?(\d{4}-\d{2}-\d{2})/m) ?? [])[1] ?? null; |
| 50 | + posts.push({ slug: entry.name, date, updatedAt }); |
| 51 | + } |
| 52 | + return posts; |
| 53 | +} |
| 54 | + |
| 55 | +/** Sorted [ ['YYYY-MM', count], ... ] histogram of publish months. */ |
| 56 | +function publishHistogram(posts) { |
| 57 | + const counts = new Map(); |
| 58 | + for (const { date } of posts) { |
| 59 | + if (!date) continue; |
| 60 | + const month = date.slice(0, 7); |
| 61 | + counts.set(month, (counts.get(month) ?? 0) + 1); |
| 62 | + } |
| 63 | + return [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)); |
| 64 | +} |
| 65 | + |
| 66 | +/** Slugs of posts published OR updated within the last RECENT_DAYS days. */ |
| 67 | +function recentSlugs(posts) { |
| 68 | + const cutoff = new Date(Date.now() - RECENT_DAYS * 86400000).toISOString().slice(0, 10); |
| 69 | + return posts |
| 70 | + .filter((p) => (p.date && p.date > cutoff) || (p.updatedAt && p.updatedAt > cutoff)) |
| 71 | + .map((p) => p.slug) |
| 72 | + .sort(); |
| 73 | +} |
| 74 | + |
| 75 | +function tuplesLiteral(hist) { |
| 76 | + return hist.map(([month, count]) => `tuple('${month}',${count})`).join(","); |
| 77 | +} |
| 78 | + |
| 79 | +function postsPerMonthQuery(tuples) { |
| 80 | + return { |
| 81 | + kind: "DataVisualizationNode", |
| 82 | + display: "ActionsBar", |
| 83 | + source: { |
| 84 | + kind: "HogQLQuery", |
| 85 | + query: `SELECT toDate(concat(t.1, '-01')) AS month, t.2 AS posts FROM (SELECT arrayJoin([${tuples}]) AS t) ORDER BY month`, |
| 86 | + }, |
| 87 | + chartSettings: { |
| 88 | + xAxis: { column: "month" }, |
| 89 | + yAxis: [{ column: "posts", settings: { display: { label: "Posts published" } } }], |
| 90 | + }, |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +function libraryAgeQuery(tuples) { |
| 95 | + const query = |
| 96 | + `WITH hist AS (SELECT toDate(concat(t.1, '-01')) AS pub, t.2 AS cnt FROM (SELECT arrayJoin([${tuples}]) AS t)) ` + |
| 97 | + `SELECT M AS month, ` + |
| 98 | + `sumIf(cnt, pub > M - toIntervalMonth(12) AND pub <= M) AS younger_than_12mo, ` + |
| 99 | + `sumIf(cnt, pub <= M - toIntervalMonth(12)) AS older_than_12mo ` + |
| 100 | + `FROM (SELECT arrayJoin(arrayMap(i -> toStartOfMonth(today()) - toIntervalMonth(i), range(0, 36))) AS M) months ` + |
| 101 | + `CROSS JOIN hist GROUP BY M ORDER BY M`; |
| 102 | + return { |
| 103 | + kind: "DataVisualizationNode", |
| 104 | + display: "ActionsStackedBar", |
| 105 | + source: { kind: "HogQLQuery", query }, |
| 106 | + chartSettings: { |
| 107 | + xAxis: { column: "month" }, |
| 108 | + yAxis: [ |
| 109 | + { column: "younger_than_12mo", settings: { display: { label: "< 12 months" } } }, |
| 110 | + { column: "older_than_12mo", settings: { display: { label: "> 12 months" } } }, |
| 111 | + ], |
| 112 | + }, |
| 113 | + }; |
| 114 | +} |
| 115 | + |
| 116 | +function recentPostsQuery(slugs) { |
| 117 | + const quote = (s) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; |
| 118 | + const arr = slugs.length ? `[${slugs.map(quote).join(",")}]` : "CAST([] AS Array(String))"; |
| 119 | + const query = |
| 120 | + `WITH recent AS (SELECT arrayJoin(${arr}) AS slug), ` + |
| 121 | + `pv AS (SELECT properties.$pathname AS path, count() AS pageviews, count(DISTINCT person_id) AS visitors ` + |
| 122 | + `FROM events WHERE event = '$pageview' AND properties.$host = 'www.prisma.io' ` + |
| 123 | + `AND timestamp >= now() - INTERVAL 30 DAY AND properties.$pathname LIKE '/blog/%' GROUP BY path) ` + |
| 124 | + `SELECT path, pageviews, visitors, rank, multiIf(rank <= 3, 'Top 3', rank > total - 3, 'Bottom 3', '') AS tier ` + |
| 125 | + `FROM (SELECT concat('/blog/', recent.slug) AS path, coalesce(pv.pageviews, 0) AS pageviews, ` + |
| 126 | + `coalesce(pv.visitors, 0) AS visitors, row_number() OVER (ORDER BY coalesce(pv.pageviews, 0) DESC) AS rank, ` + |
| 127 | + `count() OVER () AS total FROM recent LEFT JOIN pv ON pv.path = concat('/blog/', recent.slug)) ` + |
| 128 | + `ORDER BY rank LIMIT 100`; |
| 129 | + return { kind: "DataVisualizationNode", display: "ActionsTable", source: { kind: "HogQLQuery", query } }; |
| 130 | +} |
| 131 | + |
| 132 | +async function patchInsight(id, query) { |
| 133 | + const res = await fetch(`${HOST}/api/projects/${PROJECT_ID}/insights/${id}/`, { |
| 134 | + method: "PATCH", |
| 135 | + headers: { |
| 136 | + "Content-Type": "application/json", |
| 137 | + Authorization: `Bearer ${process.env.POSTHOG_API_KEY}`, |
| 138 | + }, |
| 139 | + body: JSON.stringify({ query }), |
| 140 | + }); |
| 141 | + if (!res.ok) { |
| 142 | + throw new Error(`PATCH insight ${id} failed: ${res.status} ${await res.text()}`); |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +const posts = readPosts(); |
| 147 | +const hist = publishHistogram(posts); |
| 148 | +const tuples = tuplesLiteral(hist); |
| 149 | +const total = hist.reduce((sum, [, count]) => sum + count, 0); |
| 150 | +const recent = recentSlugs(posts); |
| 151 | +console.log( |
| 152 | + `Blog posts: ${total} across ${hist.length} months (${hist[0]?.[0]} – ${hist.at(-1)?.[0]}); ${recent.length} recent (≤${RECENT_DAYS}d).`, |
| 153 | +); |
| 154 | + |
| 155 | +if (process.argv.includes("--dry-run")) { |
| 156 | + console.log("\nDRY RUN — queries that would be written (no API call):\n"); |
| 157 | + console.log("posts-per-month:\n" + postsPerMonthQuery(tuples).source.query + "\n"); |
| 158 | + console.log("library-age:\n" + libraryAgeQuery(tuples).source.query + "\n"); |
| 159 | + console.log("recent slugs (" + recent.length + "): " + recent.join(", ") + "\n"); |
| 160 | + console.log("recent-posts:\n" + recentPostsQuery(recent).source.query); |
| 161 | + process.exit(0); |
| 162 | +} |
| 163 | + |
| 164 | +if (!process.env.POSTHOG_API_KEY) { |
| 165 | + console.error("Error: POSTHOG_API_KEY is not set. Set the repo secret, or pass --dry-run to preview."); |
| 166 | + process.exit(1); |
| 167 | +} |
| 168 | + |
| 169 | +await patchInsight(INSIGHTS.postsPerMonth, postsPerMonthQuery(tuples)); |
| 170 | +console.log(`Updated posts-per-month insight (${INSIGHTS.postsPerMonth}).`); |
| 171 | +await patchInsight(INSIGHTS.libraryAge, libraryAgeQuery(tuples)); |
| 172 | +console.log(`Updated library-age insight (${INSIGHTS.libraryAge}).`); |
| 173 | +await patchInsight(INSIGHTS.recentPosts, recentPostsQuery(recent)); |
| 174 | +console.log(`Updated recent-posts insight (${INSIGHTS.recentPosts}).`); |
| 175 | +console.log("Done."); |
0 commit comments