diff --git a/gatsby/onPostBuild.ts b/gatsby/onPostBuild.ts index 17161eb78c18..38d00932d74a 100644 --- a/gatsby/onPostBuild.ts +++ b/gatsby/onPostBuild.ts @@ -172,6 +172,7 @@ const generateMarkdownArtifacts = async (graphql: any) => { data { attributes { label + slug } } } diff --git a/gatsby/rawMarkdownUtils.ts b/gatsby/rawMarkdownUtils.ts index 0a1e9aca51fc..6bacf1df3e34 100644 --- a/gatsby/rawMarkdownUtils.ts +++ b/gatsby/rawMarkdownUtils.ts @@ -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 in seo.tsx. @@ -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 = { @@ -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) diff --git a/scripts/import-changelog-docs-ctas.ts b/scripts/import-changelog-docs-ctas.ts new file mode 100644 index 000000000000..04b056e48237 --- /dev/null +++ b/scripts/import-changelog-docs-ctas.ts @@ -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 => { + 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) +}) diff --git a/src/components/Changelog/docsLinks.ts b/src/components/Changelog/docsLinks.ts new file mode 100644 index 000000000000..4fdfa3ee2b65 --- /dev/null +++ b/src/components/Changelog/docsLinks.ts @@ -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 = { + 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 +} diff --git a/src/pages/changelog/index.tsx b/src/pages/changelog/index.tsx index 2447f32663d6..54d5c3f3f0bd 100644 --- a/src/pages/changelog/index.tsx +++ b/src/pages/changelog/index.tsx @@ -67,6 +67,7 @@ export const query = graphql` data { attributes { label + slug } } } diff --git a/src/templates/Changelog.tsx b/src/templates/Changelog.tsx index d00215f645de..879eed391474 100644 --- a/src/templates/Changelog.tsx +++ b/src/templates/Changelog.tsx @@ -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' @@ -107,6 +108,7 @@ type RoadmapNode = { data?: { attributes?: { label?: string + slug?: string } } } @@ -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) @@ -336,6 +339,19 @@ const Roadmap = ({ {roadmap.description && (
{roadmap.description} + {docsPath && docsPath !== stripPostHogOrigin(roadmap.cta?.url || '') && ( +
+ + Read the docs + +
+ )}
@@ -421,14 +437,19 @@ const StaticChangelogList = ({ roadmaps }: { roadmaps: RoadmapNode[] }) => {

{dayjs.utc(month).format('MMMM YYYY')}

{items.map((roadmap) => { const teamName = roadmap.teams?.data?.[0]?.attributes?.name + const topicLabel = roadmap.topic?.data?.attributes?.label + const docsPath = getChangelogDocsPath(roadmap) return (
{roadmap.title}

- {dayjs.utc(roadmap.date).format('MMMM D, YYYY')} + + {dayjs.utc(roadmap.date).format('MMMM D, YYYY')} + {teamName ? ` · ${teamName} Team` : ''} + {topicLabel ? ` · ${topicLabel}` : ''}

{fullDetail && roadmap.description && (
@@ -438,6 +459,15 @@ const StaticChangelogList = ({ roadmaps }: { roadmaps: RoadmapNode[] }) => { {fullDetail && roadmap.cta?.url && ( {roadmap.cta.label || 'Learn more'} )} + {fullDetail && + docsPath && + docsPath !== stripPostHogOrigin(roadmap.cta?.url || '') && ( +

+ + Read the docs + +

+ )}
) })}