Port ghes-release-notes.js to TypeScript (#51196)
This commit is contained in:
@@ -37,7 +37,7 @@ import earlyAccessLinks from '@/early-access/middleware/early-access-links'
|
||||
import categoriesForSupport from './categories-for-support'
|
||||
import triggerError from '@/observability/middleware/trigger-error'
|
||||
import secretScanning from '@/secret-scanning/middleware/secret-scanning'
|
||||
import ghesReleaseNotes from '@/release-notes/middleware/ghes-release-notes.js'
|
||||
import ghesReleaseNotes from '@/release-notes/middleware/ghes-release-notes'
|
||||
import whatsNewChangelog from './context/whats-new-changelog.js'
|
||||
import layout from './context/layout.js'
|
||||
import currentProductTree from './context/current-product-tree.js'
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import semver from 'semver'
|
||||
import { supported, latestStable, latest } from '#src/versions/lib/enterprise-server-releases.js'
|
||||
import { renderContent } from '#src/content-render/index.js'
|
||||
import { supported, latestStable, latest } from '@/versions/lib/enterprise-server-releases.js'
|
||||
import { renderContent } from '@/content-render/index.js'
|
||||
import type { Context, GHESReleasePatch, ReleaseNotes } from '@/types'
|
||||
|
||||
/**
|
||||
* Create an array of release note objects and sort them by number.
|
||||
* Turn { [key]: { notes, intro, date, sections... } }
|
||||
* Into [{ version, patches: [ {notes, intro, date, sections... }] }]
|
||||
*/
|
||||
export function formatReleases(releaseNotes) {
|
||||
export function formatReleases(releaseNotes: ReleaseNotes) {
|
||||
// Get release note numbers in dot notation and sort from highest to lowest.
|
||||
const sortedReleaseNumbers = Object.keys(releaseNotes)
|
||||
.map((r) => r.replace(/-/g, '.'))
|
||||
@@ -21,11 +22,11 @@ export function formatReleases(releaseNotes) {
|
||||
// Change version-rc1 to version-rc.1 to make these proper semver RC versions.
|
||||
const patchNumberSemver = patchNumber.replace(/rc/, 'rc.')
|
||||
return {
|
||||
...notesPerVersion[patchNumber],
|
||||
version: `${releaseNumber}.${patchNumberSemver}`,
|
||||
patchVersion: patchNumberSemver,
|
||||
downloadVersion: `${releaseNumber}.${patchNumber.replace(/-rc\d*$/, '')}`, // Remove RC
|
||||
release: releaseNumber,
|
||||
...notesPerVersion[patchNumber],
|
||||
}
|
||||
})
|
||||
.sort((a, b) => semver.compare(b.version, a.version))
|
||||
@@ -50,11 +51,15 @@ export function formatReleases(releaseNotes) {
|
||||
* case of a sub-section.
|
||||
* Returns [{version, patchVersion, intro, date, sections: { features: [], bugs: []...}}]
|
||||
*/
|
||||
export async function renderPatchNotes(patches, ctx) {
|
||||
export async function renderPatchNotes(
|
||||
patches: GHESReleasePatch[],
|
||||
ctx: Context,
|
||||
): Promise<GHESReleasePatch[]> {
|
||||
return await Promise.all(
|
||||
patches.map(async (patch) => {
|
||||
// Clone the patch object but drop 'sections' so we can render them below without mutations
|
||||
const { sections, ...renderedPatch } = patch
|
||||
// const { sections } = patch
|
||||
const renderedPatch: GHESReleasePatch = { ...patch, sections: {} }
|
||||
renderedPatch.intro = await renderContent(patch.intro, ctx)
|
||||
|
||||
// Now render the sections...
|
||||
@@ -69,17 +74,19 @@ export async function renderPatchNotes(patches, ctx) {
|
||||
// where `note` may be a string or an object like { heading, notes: []}
|
||||
if (typeof note === 'string') {
|
||||
return renderContent(note, ctx)
|
||||
} else if (typeof note === 'object' && 'heading' in note && 'notes' in note) {
|
||||
return {
|
||||
heading: note.heading,
|
||||
notes: await Promise.all(
|
||||
note.notes.map(async (noteStr) => renderContent(noteStr, ctx)),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
const renderedNoteObj = {}
|
||||
renderedNoteObj.heading = note.heading
|
||||
renderedNoteObj.notes = await Promise.all(
|
||||
note.notes.map(async (noteStr) => renderContent(noteStr, ctx)),
|
||||
)
|
||||
|
||||
return renderedNoteObj
|
||||
throw new Error('Unrecognized note type')
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return [sectionType, renderedSectionArray]
|
||||
}),
|
||||
),
|
||||
@@ -1,11 +1,12 @@
|
||||
import { getDataByLanguage, getDeepDataByLanguage } from '#src/data-directory/lib/get-data.js'
|
||||
import { getDataByLanguage, getDeepDataByLanguage } from '@/data-directory/lib/get-data.js'
|
||||
import type { ReleaseNotes } from '@/types'
|
||||
|
||||
// If we one day support release-notes for other products, add it here.
|
||||
// Checking against this is only really to make sure there's no typos
|
||||
// since we don't have TypeScript to make sure the argument is valid.
|
||||
const VALID_PREFIXES = new Set(['enterprise-server', 'github-ae'])
|
||||
|
||||
export function getReleaseNotes(prefix, langCode) {
|
||||
export function getReleaseNotes(prefix: string, langCode: string) {
|
||||
if (!VALID_PREFIXES.has(prefix)) {
|
||||
throw new Error(
|
||||
`'${prefix}' is not a valid prefix for this function. Must be one of ${Array.from(
|
||||
@@ -16,7 +17,7 @@ export function getReleaseNotes(prefix, langCode) {
|
||||
// Use English as the foundation, then we'll try to load each individual
|
||||
// data/release-notes/**/*.yml file from the translation.
|
||||
// If the language is 'en', don't even bother merging.
|
||||
const releaseNotes = getDeepDataByLanguage(`release-notes.${prefix}`, 'en')
|
||||
const releaseNotes = getDeepDataByLanguage(`release-notes.${prefix}`, 'en') as ReleaseNotes
|
||||
if (langCode === 'en') {
|
||||
// Exit early because nothing special needs to be done.
|
||||
return releaseNotes
|
||||
@@ -34,7 +35,7 @@ export function getReleaseNotes(prefix, langCode) {
|
||||
// use the English ones.
|
||||
// The output of `getDeepDataByLanguage()` is a mutable object
|
||||
// from a memoize cache, so don't mutate it to avoid confusing bugs.
|
||||
const translatedReleaseNotes = {}
|
||||
const translatedReleaseNotes: ReleaseNotes = {}
|
||||
|
||||
// Now, let's iterated over all nested keys and for each one load in the
|
||||
// translated releases.
|
||||
@@ -1,9 +1,18 @@
|
||||
import { formatReleases, renderPatchNotes } from '#src/release-notes/lib/release-notes-utils.js'
|
||||
import { all } from '#src/versions/lib/enterprise-server-releases.js'
|
||||
import { executeWithFallback } from '#src/languages/lib/render-with-fallback.js'
|
||||
import { getReleaseNotes } from './get-release-notes.js'
|
||||
import type { NextFunction, Response } from 'express'
|
||||
|
||||
export default async function ghesReleaseNotesContext(req, res, next) {
|
||||
import { formatReleases, renderPatchNotes } from '@/release-notes/lib/release-notes-utils'
|
||||
import { all } from '@/versions/lib/enterprise-server-releases.js'
|
||||
import { executeWithFallback } from '@/languages/lib/render-with-fallback.js'
|
||||
import { getReleaseNotes } from './get-release-notes'
|
||||
import type { Context, ExtendedRequest } from '@/types'
|
||||
|
||||
export default async function ghesReleaseNotesContext(
|
||||
req: ExtendedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
if (!req.pagePath || !req.context || !req.context.currentVersion)
|
||||
throw new Error('request not contextualized')
|
||||
if (!(req.pagePath.endsWith('/release-notes') || req.pagePath.endsWith('/admin'))) return next()
|
||||
const [requestedPlan, requestedRelease] = req.context.currentVersion.split('@')
|
||||
if (requestedPlan !== 'enterprise-server') return next()
|
||||
@@ -38,9 +47,9 @@ export default async function ghesReleaseNotesContext(req, res, next) {
|
||||
req.context.ghesReleases = formatReleases(ghesReleaseNotes)
|
||||
|
||||
// Find the notes for the current release only
|
||||
const currentReleaseNotes = req.context.ghesReleases.find(
|
||||
(r) => r.version === requestedRelease,
|
||||
).patches
|
||||
const matchedReleaseNotes = req.context.ghesReleases.find((r) => r.version === requestedRelease)
|
||||
if (!matchedReleaseNotes) throw new Error('Release notes not found')
|
||||
const currentReleaseNotes = matchedReleaseNotes.patches
|
||||
|
||||
// This means the AUTOTITLE links are in the current language, but
|
||||
// since we're already force the source of the release notes from English
|
||||
@@ -55,15 +64,18 @@ export default async function ghesReleaseNotesContext(req, res, next) {
|
||||
// Returns the current release's patches array: [{version, patchVersion, intro, date, sections}]
|
||||
req.context.ghesReleaseNotes = await executeWithFallback(
|
||||
req.context,
|
||||
() => renderPatchNotes(currentReleaseNotes, req.context),
|
||||
(enContext) => {
|
||||
() => renderPatchNotes(currentReleaseNotes, req.context!),
|
||||
(enContext: Context) => {
|
||||
// Something in the release notes ultimately caused a Liquid
|
||||
// rendering error. Let's start over and gather the English release
|
||||
// notes instead.
|
||||
enContext.ghesReleases = formatReleases(ghesReleaseNotes)
|
||||
const currentReleaseNotes = enContext.ghesReleases.find(
|
||||
|
||||
const matchedReleaseNotes = enContext.ghesReleases!.find(
|
||||
(r) => r.version === requestedRelease,
|
||||
).patches
|
||||
)
|
||||
if (!matchedReleaseNotes) throw new Error('Release notes not found')
|
||||
const currentReleaseNotes = matchedReleaseNotes.patches
|
||||
return renderPatchNotes(currentReleaseNotes, enContext)
|
||||
},
|
||||
)
|
||||
@@ -74,7 +86,7 @@ export default async function ghesReleaseNotesContext(req, res, next) {
|
||||
|
||||
// GHES release notes on docs started with 2.20 but older release notes exist on enterprise.github.com.
|
||||
// So we want to use _all_ GHES versions when calculating next and previous releases.
|
||||
req.context.latestPatch = req.context.ghesReleaseNotes[0].version
|
||||
req.context.latestPatch = req.context.ghesReleaseNotes![0].version
|
||||
req.context.latestRelease = all[0]
|
||||
|
||||
// Add convenience props for "Supported releases" section on GHES Admin landing page (NOT release notes).
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'
|
||||
import nock from 'nock'
|
||||
|
||||
import { get, getDOM } from '#src/tests/helpers/e2etest.js'
|
||||
import enterpriseServerReleases from '#src/versions/lib/enterprise-server-releases.js'
|
||||
import { get, getDOM } from '@/tests/helpers/e2etest.js'
|
||||
import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases.js'
|
||||
|
||||
describe('release notes', () => {
|
||||
vi.setConfig({ testTimeout: 60 * 1000 })
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import enterpriseServerReleases from '#src/versions/lib/enterprise-server-releases.js'
|
||||
import { get } from '#src/tests/helpers/e2etest.js'
|
||||
import Page from '#src/frame/lib/page.js'
|
||||
import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases.js'
|
||||
import { get } from '@/tests/helpers/e2etest.js'
|
||||
import Page from '@/frame/lib/page.js'
|
||||
|
||||
// The English content page's `versions:` frontmatter is the source
|
||||
// of (convenient) truth about which versions of this page is available.
|
||||
@@ -11,6 +11,7 @@ const page = await Page.init({
|
||||
relativePath: 'admin/release-notes.md',
|
||||
languageCode: 'en',
|
||||
})
|
||||
if (!page) throw new Error('Page not found')
|
||||
|
||||
describe('server', () => {
|
||||
vi.setConfig({ testTimeout: 60 * 1000 })
|
||||
@@ -28,7 +29,7 @@ describe('server', () => {
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
const { applicableVersions } = page
|
||||
const applicableVersions = page.applicableVersions
|
||||
|
||||
test.each(applicableVersions)('version %s that has release-notes', async (version) => {
|
||||
const url = `/en/${version}/admin/release-notes`
|
||||
47
src/types.ts
47
src/types.ts
@@ -75,6 +75,52 @@ export type Context = {
|
||||
redirectNotFound?: string
|
||||
earlyAccessPageLinks?: string
|
||||
secretScanningData?: SecretScanningData[]
|
||||
ghesReleases?: GHESRelease[]
|
||||
ghesReleaseNotes?: GHESReleasePatch[]
|
||||
autotitleLanguage?: string
|
||||
latestPatch?: string
|
||||
latestRelease?: string
|
||||
}
|
||||
|
||||
export type GHESRelease = {
|
||||
version: string
|
||||
patches: GHESReleasePatch[]
|
||||
isReleaseCandidate: boolean
|
||||
firstPreviousRelease?: string
|
||||
secondPreviousRelease?: string
|
||||
}
|
||||
|
||||
type ReleasePatchSectionNote = {
|
||||
heading: string
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
type ReleasePatchSection = {
|
||||
security_fixes?: string[] | ReleasePatchSectionNote[]
|
||||
known_issues?: string[] | ReleasePatchSectionNote[]
|
||||
features?: string[] | ReleasePatchSectionNote[]
|
||||
deprecations?: string[] | ReleasePatchSectionNote[]
|
||||
bugs?: string[] | ReleasePatchSectionNote[]
|
||||
errata?: string[] | ReleasePatchSectionNote[]
|
||||
backups?: string[] | ReleasePatchSectionNote[]
|
||||
}
|
||||
|
||||
export type GHESReleasePatch = {
|
||||
version: string
|
||||
patchVersion: string
|
||||
downloadVersion: string
|
||||
release: string
|
||||
date: string
|
||||
release_candidate?: boolean
|
||||
deprecated?: boolean
|
||||
intro?: string
|
||||
sections: ReleasePatchSection
|
||||
}
|
||||
|
||||
export type ReleaseNotes = {
|
||||
[majorVersion: string]: {
|
||||
[minorVersion: string]: GHESReleasePatch
|
||||
}
|
||||
}
|
||||
|
||||
export type SecretScanningData = {
|
||||
@@ -129,6 +175,7 @@ export type Page = {
|
||||
renderProp: (prop: string, context: any, opts: any) => Promise<string>
|
||||
markdown: string
|
||||
versions: FrontmatterVersions
|
||||
applicableVersions: string[]
|
||||
}
|
||||
|
||||
export type Tree = {
|
||||
|
||||
Reference in New Issue
Block a user