@@ -8,7 +8,7 @@
|
|||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
# To update the sha:
|
# To update the sha:
|
||||||
# https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble
|
# https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble
|
||||||
FROM ghcr.io/github/gh-base-image/gh-base-noble:20251119-090131-gb27dc275c AS base
|
FROM ghcr.io/github/gh-base-image/gh-base-noble:20251217-105955-g05726ec4c AS base
|
||||||
|
|
||||||
# Install curl for Node install and determining the early access branch
|
# Install curl for Node install and determining the early access branch
|
||||||
# Install git for cloning docs-early-access & translations repos
|
# Install git for cloning docs-early-access & translations repos
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Cookies from '@/frame/components/lib/cookies'
|
import Cookies from '@/frame/components/lib/cookies'
|
||||||
|
import { ANALYTICS_ENABLED } from '@/frame/lib/constants'
|
||||||
import { parseUserAgent } from './user-agent'
|
import { parseUserAgent } from './user-agent'
|
||||||
import { Router } from 'next/router'
|
import { Router } from 'next/router'
|
||||||
import { isLoggedIn } from '@/frame/components/hooks/useHasAccount'
|
import { isLoggedIn } from '@/frame/components/hooks/useHasAccount'
|
||||||
@@ -436,8 +437,7 @@ function initPrintEvent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initializeEvents() {
|
export function initializeEvents() {
|
||||||
return
|
if (!ANALYTICS_ENABLED) return
|
||||||
// eslint-disable-next-line no-unreachable
|
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
initPageAndExitEvent() // must come first
|
initPageAndExitEvent() // must come first
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
import { turnOffExperimentsInPage, dismissCTAPopover } from '../helpers/turn-off-experiments'
|
import { turnOffExperimentsInPage, dismissCTAPopover } from '../helpers/turn-off-experiments'
|
||||||
|
import { HOVERCARDS_ENABLED, ANALYTICS_ENABLED } from '../../frame/lib/constants'
|
||||||
|
|
||||||
// This exists for the benefit of local testing.
|
// This exists for the benefit of local testing.
|
||||||
// In GitHub Actions, we rely on setting the environment variable directly
|
// In GitHub Actions, we rely on setting the environment variable directly
|
||||||
@@ -347,6 +348,8 @@ test('sidebar custom link functionality works', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test.describe('hover cards', () => {
|
test.describe('hover cards', () => {
|
||||||
|
test.skip(!HOVERCARDS_ENABLED, 'Hovercards are disabled')
|
||||||
|
|
||||||
test('hover over link', async ({ page }) => {
|
test('hover over link', async ({ page }) => {
|
||||||
await page.goto('/pages/quickstart')
|
await page.goto('/pages/quickstart')
|
||||||
await turnOffExperimentsInPage(page)
|
await turnOffExperimentsInPage(page)
|
||||||
@@ -691,6 +694,8 @@ test.describe('test nav at different viewports', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test.describe('survey', () => {
|
test.describe('survey', () => {
|
||||||
|
test.skip(!ANALYTICS_ENABLED, 'Analytics are disabled')
|
||||||
|
|
||||||
test('happy path, thumbs up and enter comment and email', async ({ page }) => {
|
test('happy path, thumbs up and enter comment and email', async ({ page }) => {
|
||||||
let fulfilled = 0
|
let fulfilled = 0
|
||||||
let hasSurveyPressedEvent = false
|
let hasSurveyPressedEvent = false
|
||||||
|
|||||||
@@ -34,3 +34,6 @@ export const minimumNotFoundHtml = `
|
|||||||
• <a href=https://docs.github.com/site-policy/privacy-policies/github-privacy-statement>Privacy</a>
|
• <a href=https://docs.github.com/site-policy/privacy-policies/github-privacy-statement>Privacy</a>
|
||||||
</small>
|
</small>
|
||||||
`.replace(/\n/g, '')
|
`.replace(/\n/g, '')
|
||||||
|
|
||||||
|
export const ANALYTICS_ENABLED = true
|
||||||
|
export const HOVERCARDS_ENABLED = true
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ type ContentContext = {
|
|||||||
|
|
||||||
// Cache for journey pages so we only filter all pages once
|
// Cache for journey pages so we only filter all pages once
|
||||||
let cachedJourneyPages: JourneyPage[] | null = null
|
let cachedJourneyPages: JourneyPage[] | null = null
|
||||||
|
// Cache for guide paths to quickly check if a page is part of any journey
|
||||||
|
let cachedGuidePaths: Set<string> | null = null
|
||||||
|
let hasDynamicGuides = false
|
||||||
|
|
||||||
|
function needsRendering(str: string): boolean {
|
||||||
|
return str.includes('{{') || str.includes('{%') || str.includes('[') || str.includes('<')
|
||||||
|
}
|
||||||
|
|
||||||
function getJourneyPages(pages: Pages): JourneyPage[] {
|
function getJourneyPages(pages: Pages): JourneyPage[] {
|
||||||
if (!cachedJourneyPages) {
|
if (!cachedJourneyPages) {
|
||||||
@@ -78,6 +85,27 @@ function getJourneyPages(pages: Pages): JourneyPage[] {
|
|||||||
return cachedJourneyPages
|
return cachedJourneyPages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGuidePaths(pages: Pages): Set<string> {
|
||||||
|
if (!cachedGuidePaths) {
|
||||||
|
cachedGuidePaths = new Set()
|
||||||
|
const journeyPages = getJourneyPages(pages)
|
||||||
|
for (const page of journeyPages) {
|
||||||
|
if (!page.journeyTracks) continue
|
||||||
|
for (const track of page.journeyTracks) {
|
||||||
|
if (!track.guides) continue
|
||||||
|
for (const guide of track.guides) {
|
||||||
|
if (needsRendering(guide.href)) {
|
||||||
|
hasDynamicGuides = true
|
||||||
|
} else {
|
||||||
|
cachedGuidePaths.add(normalizeGuidePath(guide.href))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cachedGuidePaths
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeGuidePath(path: string): string {
|
function normalizeGuidePath(path: string): string {
|
||||||
// First ensure we have a leading slash for consistent processing
|
// First ensure we have a leading slash for consistent processing
|
||||||
const pathWithSlash = path.startsWith('/') ? path : `/${path}`
|
const pathWithSlash = path.startsWith('/') ? path : `/${path}`
|
||||||
@@ -133,6 +161,16 @@ export async function resolveJourneyContext(
|
|||||||
): Promise<JourneyContext | null> {
|
): Promise<JourneyContext | null> {
|
||||||
const normalizedPath = normalizeGuidePath(articlePath)
|
const normalizedPath = normalizeGuidePath(articlePath)
|
||||||
|
|
||||||
|
// Optimization: Fast path check
|
||||||
|
// If we are not forcing a specific journey page, check our global cache
|
||||||
|
if (!currentJourneyPage) {
|
||||||
|
const guidePaths = getGuidePaths(pages)
|
||||||
|
// If we have no dynamic guides and this path isn't in our known guides, return null early.
|
||||||
|
if (!hasDynamicGuides && !guidePaths.has(normalizedPath)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use the current journey page if provided, otherwise find all journey pages
|
// Use the current journey page if provided, otherwise find all journey pages
|
||||||
const journeyPages = currentJourneyPage ? [currentJourneyPage] : getJourneyPages(pages)
|
const journeyPages = currentJourneyPage ? [currentJourneyPage] : getJourneyPages(pages)
|
||||||
|
|
||||||
@@ -165,15 +203,17 @@ export async function resolveJourneyContext(
|
|||||||
let renderedGuidePath = guidePath
|
let renderedGuidePath = guidePath
|
||||||
|
|
||||||
// Handle Liquid conditionals in guide paths
|
// Handle Liquid conditionals in guide paths
|
||||||
try {
|
if (needsRendering(guidePath)) {
|
||||||
renderedGuidePath = await executeWithFallback(
|
try {
|
||||||
context,
|
renderedGuidePath = await executeWithFallback(
|
||||||
() => renderContent(guidePath, context, { textOnly: true }),
|
context,
|
||||||
() => guidePath,
|
() => renderContent(guidePath, context, { textOnly: true }),
|
||||||
)
|
() => guidePath,
|
||||||
} catch {
|
)
|
||||||
// If rendering fails, use the original path rather than erroring
|
} catch {
|
||||||
renderedGuidePath = guidePath
|
// If rendering fails, use the original path rather than erroring
|
||||||
|
renderedGuidePath = guidePath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedGuidePath = normalizeGuidePath(renderedGuidePath)
|
const normalizedGuidePath = normalizeGuidePath(renderedGuidePath)
|
||||||
@@ -189,15 +229,17 @@ export async function resolveJourneyContext(
|
|||||||
let renderedAlternativeNextStep = alternativeNextStep
|
let renderedAlternativeNextStep = alternativeNextStep
|
||||||
|
|
||||||
// Handle Liquid conditionals in branching text which likely has links
|
// Handle Liquid conditionals in branching text which likely has links
|
||||||
try {
|
if (needsRendering(alternativeNextStep)) {
|
||||||
renderedAlternativeNextStep = await executeWithFallback(
|
try {
|
||||||
context,
|
renderedAlternativeNextStep = await executeWithFallback(
|
||||||
() => renderContent(alternativeNextStep, context),
|
context,
|
||||||
() => alternativeNextStep,
|
() => renderContent(alternativeNextStep, context),
|
||||||
)
|
() => alternativeNextStep,
|
||||||
} catch {
|
)
|
||||||
// If rendering fails, use the original branching text rather than erroring
|
} catch {
|
||||||
renderedAlternativeNextStep = alternativeNextStep
|
// If rendering fails, use the original branching text rather than erroring
|
||||||
|
renderedAlternativeNextStep = alternativeNextStep
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
@@ -278,10 +320,14 @@ export async function resolveJourneyTracks(
|
|||||||
const result = await Promise.all(
|
const result = await Promise.all(
|
||||||
journeyTracks.map(async (track) => {
|
journeyTracks.map(async (track) => {
|
||||||
// Render Liquid templates in title and description
|
// Render Liquid templates in title and description
|
||||||
const renderedTitle = await renderContent(track.title, context, { textOnly: true })
|
const renderedTitle = needsRendering(track.title)
|
||||||
const renderedDescription = track.description
|
? await renderContent(track.title, context, { textOnly: true })
|
||||||
? await renderContent(track.description, context, { textOnly: true })
|
: track.title
|
||||||
: undefined
|
|
||||||
|
const renderedDescription =
|
||||||
|
track.description && needsRendering(track.description)
|
||||||
|
? await renderContent(track.description, context, { textOnly: true })
|
||||||
|
: track.description
|
||||||
|
|
||||||
const guides = await Promise.all(
|
const guides = await Promise.all(
|
||||||
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
|
track.guides.map(async (guide: { href: string; alternativeNextStep?: string }) => {
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import type { Response, NextFunction } from 'express'
|
import type { Response, NextFunction } from 'express'
|
||||||
import type { ExtendedRequest, Context } from '@/types'
|
import type { ExtendedRequest, Context } from '@/types'
|
||||||
|
|
||||||
|
import { resolveJourneyTracks, resolveJourneyContext } from '../lib/journey-path-resolver'
|
||||||
|
|
||||||
export default async function journeyTrack(
|
export default async function journeyTrack(
|
||||||
req: ExtendedRequest & { context: Context },
|
req: ExtendedRequest & { context: Context },
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
) {
|
) {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return next()
|
||||||
|
|
||||||
if (!req.context) throw new Error('request is not contextualized')
|
if (!req.context) throw new Error('request is not contextualized')
|
||||||
if (!req.context.page) return next()
|
if (!req.context.page) return next()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const journeyResolver = await import('../lib/journey-path-resolver')
|
|
||||||
|
|
||||||
// If this page has journey tracks defined, resolve them for the landing page
|
// If this page has journey tracks defined, resolve them for the landing page
|
||||||
if ((req.context.page as any).journeyTracks) {
|
if ((req.context.page as any).journeyTracks) {
|
||||||
const resolvedTracks = await journeyResolver.resolveJourneyTracks(
|
const resolvedTracks = await resolveJourneyTracks(
|
||||||
(req.context.page as any).journeyTracks,
|
(req.context.page as any).journeyTracks,
|
||||||
req.context,
|
req.context,
|
||||||
)
|
)
|
||||||
@@ -24,7 +26,7 @@ export default async function journeyTrack(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Always try to resolve journey context (for navigation on guide articles)
|
// Always try to resolve journey context (for navigation on guide articles)
|
||||||
const journeyContext = await journeyResolver.resolveJourneyContext(
|
const journeyContext = await resolveJourneyContext(
|
||||||
req.pagePath || '',
|
req.pagePath || '',
|
||||||
req.context.pages || {},
|
req.context.pages || {},
|
||||||
req.context,
|
req.context,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { HOVERCARDS_ENABLED } from '@/frame/lib/constants'
|
||||||
|
|
||||||
// We postpone the initial delay a bit in case the user didn't mean to
|
// We postpone the initial delay a bit in case the user didn't mean to
|
||||||
// hover over the link. Perhaps they just dragged the mouse over on their
|
// hover over the link. Perhaps they just dragged the mouse over on their
|
||||||
@@ -450,6 +451,8 @@ export function LinkPreviewPopover() {
|
|||||||
// This is to track if the user entirely tabs out of the window.
|
// This is to track if the user entirely tabs out of the window.
|
||||||
// For example if they go to the address bar.
|
// For example if they go to the address bar.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!HOVERCARDS_ENABLED) return
|
||||||
|
|
||||||
function windowBlur() {
|
function windowBlur() {
|
||||||
popoverHide()
|
popoverHide()
|
||||||
}
|
}
|
||||||
@@ -460,6 +463,8 @@ export function LinkPreviewPopover() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!HOVERCARDS_ENABLED) return
|
||||||
|
|
||||||
function showPopover(event: MouseEvent) {
|
function showPopover(event: MouseEvent) {
|
||||||
const target = event.currentTarget as HTMLLinkElement
|
const target = event.currentTarget as HTMLLinkElement
|
||||||
popoverShow(target)
|
popoverShow(target)
|
||||||
|
|||||||
@@ -57,3 +57,37 @@ Slack: `#docs-engineering`
|
|||||||
Repo: `github/docs-engineering`
|
Repo: `github/docs-engineering`
|
||||||
|
|
||||||
If you have a question about the webhooks pipeline, you can ask in the `#docs-engineering` Slack channel. If you notice a problem with the webhooks pipeline, you can open an issue in the `github/docs-engineering` repository.
|
If you have a question about the webhooks pipeline, you can ask in the `#docs-engineering` Slack channel. If you notice a problem with the webhooks pipeline, you can open an issue in the `github/docs-engineering` repository.
|
||||||
|
|
||||||
|
## Ownership & Escalation
|
||||||
|
|
||||||
|
### Ownership
|
||||||
|
- **Team**: Docs Engineering
|
||||||
|
- **Source data**: API Platform (github/rest-api-description)
|
||||||
|
|
||||||
|
### Escalation path
|
||||||
|
1. **Pipeline failures** → #docs-engineering Slack
|
||||||
|
2. **OpenAPI schema issues** → #api-platform Slack
|
||||||
|
3. **Production incidents** → #docs-engineering
|
||||||
|
|
||||||
|
### On-call procedures
|
||||||
|
If the webhooks pipeline fails:
|
||||||
|
1. Check workflow logs in `.github/workflows/sync-openapi.yml`
|
||||||
|
2. Verify access to `github/rest-api-description` repo
|
||||||
|
3. Check for OpenAPI schema validation errors
|
||||||
|
4. Review changes in generated data files
|
||||||
|
5. Check `config.json` SHA tracking
|
||||||
|
6. Escalate to API Platform team if schema issue
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
- Pipeline runs automatically on daily schedule (shared with REST/GitHub Apps)
|
||||||
|
- PRs created with `github-openapi-bot` label
|
||||||
|
- SHA tracking in `config.json` for version history
|
||||||
|
- Failures visible in GitHub Actions
|
||||||
|
|
||||||
|
This pipeline is in maintenance mode. We will continue to support ongoing improvements incoming from the platform but we are not expecting new functionality moving forward.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
- **Shared pipeline** - Cannot run webhooks independently of REST/GitHub Apps
|
||||||
|
- **Single page** - All events on one page (may impact performance)
|
||||||
|
- **Introduction placement** - Manual content must be at start of file
|
||||||
|
- **Payload complexity** - Some payloads are very large and complex
|
||||||
|
|||||||
Reference in New Issue
Block a user