Skip to content
Open
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
1 change: 1 addition & 0 deletions gatsby/onPostBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const generateMarkdownArtifacts = async (graphql: any) => {
data {
attributes {
label
slug
}
}
}
Expand Down
16 changes: 13 additions & 3 deletions gatsby/rawMarkdownUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
postProcessMarkdown,
preprocessHtmlForTabs,
} from './turndownService'
import { getChangelogDocsPath, stripPostHogOrigin } from '../src/components/Changelog/docsLinks'

// Prepended to every generated .md file so LLM crawlers landing on a single page
// still see the pointer to the full index. Pairs with <link rel="llms.txt"> in seo.tsx.
Expand Down Expand Up @@ -626,7 +627,7 @@ type ChangelogRoadmapNode = {
date: string
cta?: { label?: string; url?: string }
teams?: { data?: Array<{ attributes?: { name?: string } }> }
topic?: { data?: { attributes?: { label?: string } } }
topic?: { data?: { attributes?: { label?: string; slug?: string } } }
}

type ChangelogVideoNode = {
Expand Down Expand Up @@ -668,8 +669,17 @@ export const generateChangelogMd = (roadmaps: ChangelogRoadmapNode[], videos: Ch
const meta = [dayTitle(roadmap.date), team && `${team} Team`, topic].filter(Boolean).join(' · ')
const description = cleanDescription(roadmap.description)
const cta = roadmap.cta?.url ? `[${roadmap.cta.label || 'Learn more'}](${absoluteUrl(roadmap.cta.url)})` : ''

return [`### ${roadmap.title}`, `_${meta}_`, description, cta].filter(Boolean).join('\n\n')
const docsPath = getChangelogDocsPath(roadmap)
const docs =
docsPath && docsPath !== stripPostHogOrigin(roadmap.cta?.url || '')
? `[Docs](${absoluteUrl(docsPath)})`
: ''
const links = [docs, cta].filter(Boolean).join(' · ')
const title = roadmap.strapiID
? `### [${roadmap.title}](https://posthog.com/changelog?id=${roadmap.strapiID})`
: `### ${roadmap.title}`

return [title, `_${meta}_`, description, links].filter(Boolean).join('\n\n')
}

// Group entries and videos by YYYY-MM, newest first (input is sorted date DESC)
Expand Down
111 changes: 111 additions & 0 deletions scripts/import-changelog-docs-ctas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* One-time import of docs links into the Strapi CTA field for changelog entries.
*
* For each completed roadmap (changelog) entry that has no CTA, this script
* finds the first /docs link in the entry's own description and writes it to
* the entry's CTA field as "Read the docs". It does not touch entries that
* already have a CTA, and it does not import the generic topic fallbacks —
* those stay derived at render time (see src/components/Changelog/docsLinks.ts).
*
* Usage:
* npx --yes tsx@4.20.6 scripts/import-changelog-docs-ctas.ts # dry run: list planned writes
* STRAPI_TOKEN=... npx --yes tsx@4.20.6 scripts/import-changelog-docs-ctas.ts --write
*
* Reads use the public API (STRAPI_API_HOST or GATSBY_SQUEAK_API_HOST).
* Writes require STRAPI_TOKEN with update permission on the roadmap type.
*/
import path from 'path'
import dotenv from 'dotenv'
import qs from 'qs'
import { getDescriptionDocsPath } from '../src/components/Changelog/docsLinks'

dotenv.config({ path: path.resolve(process.cwd(), '.env.production') })
dotenv.config({ path: path.resolve(process.cwd(), '.env') })

const apiHost = process.env.STRAPI_API_HOST || process.env.GATSBY_SQUEAK_API_HOST
const write = process.argv.includes('--write')

type RoadmapEntry = {
id: number
attributes: {
title?: string
description?: string
dateCompleted?: string
projectedCompletion?: string
cta?: { label?: string; url?: string }
}
}

const fetchCompletedRoadmaps = async (): Promise<RoadmapEntry[]> => {
const entries: RoadmapEntry[] = []
let page = 1
let pageCount = 1
while (page <= pageCount) {
const query = qs.stringify(
{
pagination: { page, pageSize: 100 },
filters: { complete: { $eq: true } },
populate: { cta: true, topic: true },
},
{ encodeValuesOnly: true }
)
const res = await fetch(`${apiHost}/api/roadmaps?${query}`)
if (!res.ok) throw new Error(`Failed to fetch roadmaps (page ${page}): ${res.status}`)
const { data, meta } = await res.json()
entries.push(...(data || []))
pageCount = meta?.pagination?.pageCount || page
page++
}
return entries
}

const updateRoadmapCta = async (id: number, url: string) => {
const res = await fetch(`${apiHost}/api/roadmaps/${id}`, {
method: 'PUT',
body: JSON.stringify({ data: { cta: { label: 'Read the docs', url } } }),
headers: {
Authorization: `Bearer ${process.env.STRAPI_TOKEN}`,
'content-type': 'application/json',
},
})
const { error } = await res.json()
if (error) throw new Error(JSON.stringify(error))
}

const main = async () => {
if (!apiHost) throw new Error('Set STRAPI_API_HOST or GATSBY_SQUEAK_API_HOST')
if (write && !process.env.STRAPI_TOKEN) throw new Error('--write requires STRAPI_TOKEN')

const entries = await fetchCompletedRoadmaps()
const changelogEntries = entries.filter(
({ attributes }) => attributes.dateCompleted || attributes.projectedCompletion
)
const toImport = changelogEntries
.map((entry) => ({ entry, docsPath: getDescriptionDocsPath(entry.attributes.description) }))
.filter(({ entry, docsPath }) => docsPath && !entry.attributes.cta?.url)

console.log(`${changelogEntries.length} changelog entries, ${toImport.length} with an empty CTA and a docs link`)

let failures = 0
for (const { entry, docsPath } of toImport) {
if (write) {
try {
await updateRoadmapCta(entry.id, docsPath as string)
console.log(`updated ${entry.id}: ${entry.attributes.title} -> ${docsPath}`)
} catch (err) {
failures++
console.error(`failed ${entry.id}: ${entry.attributes.title}`, err)
}
} else {
console.log(`would update ${entry.id}: ${entry.attributes.title} -> ${docsPath}`)
}
}

if (!write) console.log('Dry run — pass --write with STRAPI_TOKEN set to apply')
if (failures) process.exit(1)
}

main().catch((err) => {
console.error(err)
process.exit(1)
})
70 changes: 70 additions & 0 deletions src/components/Changelog/docsLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Maps Squeak topic slugs to the docs page for that product area. Only topics
// with an unambiguous docs home belong here — general topics (bugs, more,
// uncategorized, community) are intentionally left out.
export const CHANGELOG_TOPIC_DOCS: Record<string, string> = {
api: '/docs/api',
apps: '/docs/cdp',
'batch-exports': '/docs/cdp/batch-exports',
cdp: '/docs/cdp',
cohorts: '/docs/data/cohorts',
dashboards: '/docs/product-analytics/dashboards',
'data-pipelines': '/docs/cdp',
'data-warehouse': '/docs/data-warehouse',
desktop: '/docs/posthog-desktop',
endpoints: '/docs/endpoints',
'error-tracking': '/docs/error-tracking',
'events-actions': '/docs/data/events',
experiments: '/docs/experiments',
'feature-flags': '/docs/feature-flags',
funnels: '/docs/product-analytics/funnels',
groups: '/docs/product-analytics/group-analytics',
heatmaps: '/docs/toolbar/heatmaps',
hogql: '/docs/sql',
inbox: '/docs/self-driving/inbox',
'llm-analytics': '/docs/ai-observability',
'max-ai': '/docs/posthog-ai',
mcp: '/docs/model-context-protocol',
notebooks: '/docs/notebooks',
paths: '/docs/product-analytics/paths',
'people-and-properties': '/docs/data/persons',
pricing: '/docs/billing',
'product-analytics': '/docs/product-analytics',
retention: '/docs/product-analytics/retention',
sdks: '/docs/libraries',
'self-driving': '/docs/self-driving',
'session-replay': '/docs/session-replay',
sessions: '/docs/data/sessions',
slack: '/docs/slack',
surveys: '/docs/surveys',
toolbar: '/docs/toolbar',
trends: '/docs/product-analytics/trends',
'web-analytics': '/docs/web-analytics',
workflows: '/docs/workflows',
}

type ChangelogDocsSource = {
description?: string
cta?: { label?: string; url?: string }
topic?: { data?: { attributes?: { label?: string; slug?: string } } }
}

export const stripPostHogOrigin = (url: string): string => url.replace(/^https?:\/\/(www\.)?posthog\.com/, '')

// The first /docs link written into an entry's Markdown description, if any.
export const getDescriptionDocsPath = (description?: string): string | null => {
const match = description?.match(/\]\((?:https?:\/\/(?:www\.)?posthog\.com)?(\/docs\/[^)\s]+)\)/)
return match ? match[1] : null
}

// The docs page for a changelog entry: an explicit docs CTA wins, then the
// first /docs link written into the description, then the topic's docs home.
export const getChangelogDocsPath = (roadmap: ChangelogDocsSource): string | null => {
const ctaPath = stripPostHogOrigin(roadmap.cta?.url || '')
if (ctaPath.startsWith('/docs/')) return ctaPath

const descriptionPath = getDescriptionDocsPath(roadmap.description)
if (descriptionPath) return descriptionPath

const topicSlug = roadmap.topic?.data?.attributes?.slug
return (topicSlug && CHANGELOG_TOPIC_DOCS[topicSlug]) || null
}
1 change: 1 addition & 0 deletions src/pages/changelog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const query = graphql`
data {
attributes {
label
slug
}
}
}
Expand Down
32 changes: 31 additions & 1 deletion src/templates/Changelog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AnimatePresence, motion, PanInfo } from 'framer-motion'
import Markdown from 'components/Squeak/components/Markdown'
import Link from 'components/Link'
import Filters from 'components/Changelog/Filters'
import { getChangelogDocsPath, stripPostHogOrigin } from 'components/Changelog/docsLinks'
import { GatsbyImage } from 'gatsby-plugin-image'
import type { IGatsbyImageData } from 'gatsby-plugin-image'
import { useWindow } from '../context/Window'
Expand Down Expand Up @@ -107,6 +108,7 @@ type RoadmapNode = {
data?: {
attributes?: {
label?: string
slug?: string
}
}
}
Expand Down Expand Up @@ -174,6 +176,7 @@ const Roadmap = ({
const { isModerator, getJwt } = useUser()
const { addWindow } = useApp()
const hasProfiles = (roadmap.profiles?.data?.length ?? 0) > 0
const docsPath = getChangelogDocsPath(roadmap)
const [width, setWidth] = useState(450)
const [isResizing, setIsResizing] = useState(false)

Expand Down Expand Up @@ -336,6 +339,19 @@ const Roadmap = ({
{roadmap.description && (
<div className="py-2 px-4">
<Markdown>{roadmap.description}</Markdown>
{docsPath && docsPath !== stripPostHogOrigin(roadmap.cta?.url || '') && (
<div className="mt-4">
<OSButton
asLink
to={docsPath}
variant="secondary"
width="full"
state={{ newWindow: true }}
>
Read the docs
</OSButton>
</div>
)}
<div className="mt-8 mb-4 flex flex-row flex-wrap gap-1">
<ChangelogEmojiReactions roadmapId={roadmap.id} />
</div>
Expand Down Expand Up @@ -421,14 +437,19 @@ const StaticChangelogList = ({ roadmaps }: { roadmaps: RoadmapNode[] }) => {
<h2>{dayjs.utc(month).format('MMMM YYYY')}</h2>
{items.map((roadmap) => {
const teamName = roadmap.teams?.data?.[0]?.attributes?.name
const topicLabel = roadmap.topic?.data?.attributes?.label
const docsPath = getChangelogDocsPath(roadmap)
return (
<article key={roadmap.id} className="mb-6">
<Heading as="h3" id={slugify(roadmap.title, { lower: true })} className="m-0">
{roadmap.title}
</Heading>
<p className="m-0 text-sm opacity-60">
{dayjs.utc(roadmap.date).format('MMMM D, YYYY')}
<a href={`/changelog?id=${roadmap.id}`}>
{dayjs.utc(roadmap.date).format('MMMM D, YYYY')}
</a>
{teamName ? ` · ${teamName} Team` : ''}
{topicLabel ? ` · ${topicLabel}` : ''}
</p>
{fullDetail && roadmap.description && (
<div className="mt-2">
Expand All @@ -438,6 +459,15 @@ const StaticChangelogList = ({ roadmaps }: { roadmaps: RoadmapNode[] }) => {
{fullDetail && roadmap.cta?.url && (
<Link to={roadmap.cta.url}>{roadmap.cta.label || 'Learn more'}</Link>
)}
{fullDetail &&
docsPath &&
docsPath !== stripPostHogOrigin(roadmap.cta?.url || '') && (
<p className="m-0 text-sm">
<Link to={docsPath} state={{ newWindow: true }}>
Read the docs
</Link>
</p>
)}
</article>
)
})}
Expand Down
Loading