Skip to content

Commit 05d5ed3

Browse files
authored
Rewrite pageinfo as /article/meta (#54747)
1 parent 68502cc commit 05d5ed3

File tree

9 files changed

+194
-240
lines changed

9 files changed

+194
-240
lines changed

src/article-api/README-pageinfo.md

-17
This file was deleted.

src/article-api/README.md

+14
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,17 @@ This subject folder contains the code for the Article API endpoints:
88
## What it does
99

1010
Article API endpoints allow consumers to query GitHub Docs for listings of current articles, and for specific article information.
11+
12+
The `/api/article/meta` endpoint powers hovercards, which provide a preview for internal links on <docs.github.com>.
13+
14+
## How it works
15+
16+
The `/api/article` endpoints return information about a page by `pathname`.
17+
18+
`api/article/meta` is highly cached, in JSON format.
19+
20+
## How to get help
21+
22+
For internal folks ask in the Docs Engineering slack channel.
23+
24+
For open source folks, please open a discussion in the public repository.
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Response } from 'express'
2+
3+
import { Context } from '#src/types.js'
4+
import { ExtendedRequestWithPageInfo } from '../types'
5+
import contextualize from '#src/frame/middleware/context/context.js'
6+
7+
export async function getArticleBody(req: ExtendedRequestWithPageInfo) {
8+
// req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware
9+
// and is in the ExtendedRequestWithPageInfo
10+
const { page, pathname, archived } = req.pageinfo
11+
12+
if (archived?.isArchived)
13+
throw new Error(`Page ${pathname} is archived and can't be rendered in markdown.`)
14+
// for anything that's not an article (like index pages), don't try to render and
15+
// tell the user what's going on
16+
if (page.documentType !== 'article') {
17+
throw new Error(`Page ${pathname} isn't yet available in markdown.`)
18+
}
19+
// these parts allow us to render the page
20+
const mockedContext: Context = {}
21+
const renderingReq = {
22+
path: pathname,
23+
language: page.languageCode,
24+
pagePath: pathname,
25+
cookies: {},
26+
context: mockedContext,
27+
headers: {
28+
'content-type': 'text/markdown',
29+
},
30+
}
31+
32+
// contextualize and render the page
33+
await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {})
34+
renderingReq.context.page = page
35+
renderingReq.context.markdownRequested = true
36+
return await page.render(renderingReq.context)
37+
}
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
import express from 'express'
2-
import type { RequestHandler, Response } from 'express'
1+
import type { Response } from 'express'
32
import type { ExtendedRequestWithPageInfo } from '../types'
43

54
import type { ExtendedRequest, Page, Context, Permalink } from '@/types'
6-
import statsd from '@/observability/lib/statsd.js'
7-
import { defaultCacheControl } from '@/frame/middleware/cache-control.js'
8-
import catchMiddlewareError from '@/observability/middleware/catch-middleware-error.js'
9-
import {
10-
SURROGATE_ENUMS,
11-
setFastlySurrogateKey,
12-
makeLanguageSurrogateKey,
13-
} from '@/frame/middleware/set-fastly-surrogate-key.js'
145
import shortVersions from '@/versions/middleware/short-versions.js'
156
import contextualize from '@/frame/middleware/context/context'
167
import features from '@/versions/middleware/features.js'
178
import { readCompressedJsonFile } from '@/frame/lib/read-json-file.js'
18-
import { pathValidationMiddleware, pageValidationMiddleware } from './validation'
19-
20-
const router = express.Router()
219

2210
// If you have pre-computed page info into a JSON file on disk, this is
2311
// where it would be expected to be found.
@@ -96,7 +84,7 @@ type CachedPageInfo = {
9684
}
9785

9886
let _cache: CachedPageInfo | null = null
99-
async function getPageInfoFromCache(page: Page, pathname: string) {
87+
export async function getPageInfoFromCache(page: Page, pathname: string) {
10088
let cacheInfo = ''
10189
if (_cache === null) {
10290
try {
@@ -111,12 +99,12 @@ async function getPageInfoFromCache(page: Page, pathname: string) {
11199
}
112100
}
113101

114-
let info = _cache[pathname]
102+
let meta = _cache[pathname]
115103
if (!cacheInfo) {
116-
cacheInfo = info ? 'hit' : 'miss'
104+
cacheInfo = meta ? 'hit' : 'miss'
117105
}
118-
if (!info) {
119-
info = await getPageInfo(page, pathname)
106+
if (!meta) {
107+
meta = await getPageInfo(page, pathname)
120108
// You might wonder; why do we not store this compute information
121109
// into the `_cache` from here?
122110
// The short answer is; it won't be used again.
@@ -128,74 +116,37 @@ async function getPageInfoFromCache(page: Page, pathname: string) {
128116
// In CI, we use the caching because the CI runs
129117
// `npm run precompute-pageinfo` right before it runs vitest tests.
130118
}
131-
info.cacheInfo = cacheInfo
132-
return info
119+
meta.cacheInfo = cacheInfo
120+
return meta
133121
}
134122

135-
router.get(
136-
'/v1',
137-
pathValidationMiddleware as RequestHandler,
138-
pageValidationMiddleware as RequestHandler,
139-
catchMiddlewareError(async function pageInfo(req: ExtendedRequestWithPageInfo, res: Response) {
140-
// Remember, the `validationMiddleware` will use redirects if the
141-
// `pathname` used is a redirect (e.g. /en/articles/foo or
142-
// /articles or '/en/enterprise-server@latest/foo/bar)
143-
// So by the time we get here, the pathname should be one of the
144-
// page's valid permalinks.
145-
const { page, pathname, archived } = req.pageinfo
146-
147-
if (archived && archived.isArchived) {
148-
const { requestedVersion } = archived
149-
const title = `GitHub Enterprise Server ${requestedVersion} Help Documentation`
150-
const intro = ''
151-
const product = 'GitHub Enterprise Server'
152-
defaultCacheControl(res)
153-
return res.json({ info: { intro, title, product } })
154-
}
123+
export async function getMetadata(req: ExtendedRequestWithPageInfo) {
124+
// Remember, the `validationMiddleware` will use redirects if the
125+
// `pathname` used is a redirect (e.g. /en/articles/foo or
126+
// /articles or '/en/enterprise-server@latest/foo/bar)
127+
// So by the time we get here, the pathname should be one of the
128+
// page's valid permalinks.
129+
const { page, pathname, archived } = req.pageinfo
130+
131+
if (archived && archived.isArchived) {
132+
const { requestedVersion } = archived
133+
const title = `GitHub Enterprise Server ${requestedVersion} Help Documentation`
134+
const intro = ''
135+
const product = 'GitHub Enterprise Server'
136+
return { meta: { intro, title, product } }
137+
}
155138

156-
if (!page) {
157-
return res.status(400).json({ error: `No page found for '${pathname}'` })
158-
}
139+
if (!page) {
140+
throw new Error(`No page found for '${pathname}'`)
141+
}
159142

160-
const pagePermalinks = page.permalinks.map((p: Permalink) => p.href)
161-
if (!pagePermalinks.includes(pathname)) {
162-
throw new Error(`pathname '${pathname}' not one of the page's permalinks`)
163-
}
143+
const pagePermalinks = page.permalinks.map((p: Permalink) => p.href)
144+
if (!pagePermalinks.includes(pathname)) {
145+
throw new Error(`pathname '${pathname}' not one of the page's permalinks`)
146+
}
164147

165-
const fromCache = await getPageInfoFromCache(page, pathname)
166-
const { cacheInfo, ...info } = fromCache
167-
168-
const tags = [
169-
// According to https://docs.datadoghq.com/getting_started/tagging/#define-tags
170-
// the max length of a tag is 200 characters. Most of ours are less than
171-
// that but we truncate just to be safe.
172-
`pathname:${pathname}`.slice(0, 200),
173-
`language:${page.languageCode}`,
174-
`cache:${cacheInfo}`,
175-
]
176-
statsd.increment('pageinfo.lookup', 1, tags)
177-
178-
defaultCacheControl(res)
179-
180-
// This is necessary so that the `Surrogate-Key` header is set with
181-
// the correct language surrogate key bit. By default, it's set
182-
// from the pathname but `/api/**` URLs don't have a language
183-
// (other than the default 'en').
184-
// We do this so that all of these URLs are cached in Fastly by language
185-
// which we need for the staggered purge.
186-
187-
setFastlySurrogateKey(
188-
res,
189-
`${SURROGATE_ENUMS.DEFAULT} ${makeLanguageSurrogateKey(page.languageCode)}`,
190-
true,
191-
)
192-
res.status(200).json({ info })
193-
}),
194-
)
195-
196-
// Alias for the latest version
197-
router.get('/', (req, res) => {
198-
res.redirect(307, req.originalUrl.replace('/pageinfo', '/pageinfo/v1'))
199-
})
200-
201-
export default router
148+
const fromCache = await getPageInfoFromCache(page, pathname)
149+
const { cacheInfo, ...meta } = fromCache
150+
151+
return { meta, cacheInfo }
152+
}

0 commit comments

Comments
 (0)