1
0
mirror of synced 2025-12-19 18:10:59 -05:00

Migrate TypeScript: 9 files converted (#57928)

This commit is contained in:
Kevin Heis
2025-10-14 09:10:27 -07:00
committed by GitHub
parent 4ac96b19bd
commit f3c832edd2
9 changed files with 181 additions and 79 deletions

View File

@@ -2,9 +2,19 @@ import { Tokenizer, TokenKind } from 'liquidjs'
import { deprecated } from '@/versions/lib/enterprise-server-releases'
const liquidTokenCache = new Map()
// Using `any` for the cache because TopLevelToken is a complex union type from liquidjs
// that includes TagToken, OutputToken, and HTMLToken with different properties.
// The cache is private to this module and we control all access to it.
const liquidTokenCache = new Map<string, any>()
export function getLiquidTokens(content, { noCache = false } = {}) {
// Returns `any[]` instead of `TopLevelToken[]` because TopLevelToken is a union type
// (TagToken | OutputToken | HTMLToken) and consumers of this function access properties
// like `name` and `args` that only exist on TagToken. Using `any` here avoids complex
// type narrowing throughout the codebase.
export function getLiquidTokens(
content: string,
{ noCache = false }: { noCache?: boolean } = {},
): any[] {
if (!content) return []
if (noCache) {
@@ -30,7 +40,12 @@ export const TAG_CLOSE = '}}'
export const conditionalTags = ['if', 'elseif', 'unless', 'case', 'ifversion']
const CONDITIONAL_TAG_NAMES = ['if', 'ifversion', 'elsif', 'else', 'endif']
export function getPositionData(token, lines) {
// Token is `any` because it's used with different token types from liquidjs
// that all have `begin` and `end` properties but are part of complex union types.
export function getPositionData(
token: any,
lines: string[],
): { lineNumber: number; column: number; length: number } {
// Liquid indexes are 0-based, but we want to
// covert to the system used by Markdownlint
const begin = token.begin + 1
@@ -62,9 +77,14 @@ export function getPositionData(token, lines) {
* by Markdownlint:
* [ { lineNumber: 1, column: 1, deleteCount: 3, }]
*/
export function getContentDeleteData(token, tokenEnd, lines) {
// Token is `any` because it's used with different token types from liquidjs.
export function getContentDeleteData(
token: any,
tokenEnd: number,
lines: string[],
): Array<{ lineNumber: number; column: number; deleteCount: number }> {
const { lineNumber, column } = getPositionData(token, lines)
const errorInfo = []
const errorInfo: Array<{ lineNumber: number; column: number; deleteCount: number }> = []
let begin = column - 1
// Subtract one from end of next token tag. The end of the
// current tag is one position before that.
@@ -103,13 +123,15 @@ export function getContentDeleteData(token, tokenEnd, lines) {
// related elsif, else, and endif tags).
// Docs doesn't use the standard `if` tag for versioning, instead the
// `ifversion` tag is used.
export function getLiquidIfVersionTokens(content) {
// Returns `any[]` because the tokens need to be accessed as TagToken with `name` and `args` properties,
// but TopLevelToken union type would require complex type narrowing.
export function getLiquidIfVersionTokens(content: string): any[] {
const tokens = getLiquidTokens(content)
.filter((token) => token.kind === TokenKind.Tag)
.filter((token) => CONDITIONAL_TAG_NAMES.includes(token.name))
let inIfStatement = false
const ifVersionTokens = []
const ifVersionTokens: any[] = []
for (const token of tokens) {
if (token.name === 'if') {
inIfStatement = true
@@ -125,7 +147,7 @@ export function getLiquidIfVersionTokens(content) {
return ifVersionTokens
}
export function getSimplifiedSemverRange(release) {
export function getSimplifiedSemverRange(release: string): string {
// Liquid conditionals only use the format > or < but not
// >= or <=. Not sure exactly why.
// if startswith >, we'll check to see if the release number

View File

@@ -2,10 +2,21 @@ import fs from 'fs'
import yaml from 'js-yaml'
const allowedCodeFenceLanguages = Object.keys(
yaml.load(fs.readFileSync('data/code-languages.yml', 'utf8')),
yaml.load(fs.readFileSync('data/code-languages.yml', 'utf8')) as Record<string, unknown>,
)
export const baseConfig = {
type RuleConfig = {
severity: 'error' | 'warning'
'partial-markdown-files': boolean
'yml-files': boolean
[key: string]: any
}
type BaseConfig = {
[key: string]: boolean | RuleConfig
}
export const baseConfig: BaseConfig = {
// Don't run all rules by default. This must be done first to
// enable a specific set of rules.
default: false,

View File

@@ -47,7 +47,7 @@ function getDeletedContentFiles() {
return getContentFiles(process.env.DELETED_FILES)
}
function getContentFiles(spaceSeparatedList) {
function getContentFiles(spaceSeparatedList: string | undefined): string[] {
return (spaceSeparatedList || '').split(/\s+/g).filter((filePath) => {
// This filters out things like '', or `data/foo.md` or `content/something/README.md`
return (
@@ -69,9 +69,11 @@ describe('changed-content', () => {
// `test.each` will throw if the array is empty, so we need to add a dummy
// when there are no changed files in the environment.
if (!changedContentFiles.length) changedContentFiles.push(EMPTY)
const testFiles: Array<string | symbol> = changedContentFiles.length
? changedContentFiles
: [EMPTY]
test.each(changedContentFiles)('changed-content: %s', async (file) => {
test.each(testFiles)('changed-content: %s', async (file: string | symbol) => {
// Necessary because `test.each` will throw if the array is empty
if (file === EMPTY) return
@@ -79,13 +81,13 @@ describe('changed-content', () => {
return path.join(p.basePath, p.relativePath) === file
})
if (!page) {
throw new Error(`Could not find page for ${file} in all loaded English content`)
throw new Error(`Could not find page for ${file as string} in all loaded English content`)
}
// Each version of the page should successfully render
for (const { href } of page.permalinks) {
const res = await get(href)
if (!res.ok) {
let msg = `This error happened when rendering from: ${file}\n`
let msg = `This error happened when rendering from: ${file as string}\n`
msg +=
'To see the full error from vitest re-run the test with DEBUG_MIDDLEWARE_TESTS=true set\n'
msg += `Or, to view it locally start the server (npm run dev) and visit http://localhost:4000${href}`
@@ -101,9 +103,11 @@ describe('deleted-content', () => {
// `test.each` will throw if the array is empty, so we need to add a dummy
// when there are no deleted files in the environment.
if (!deletedContentFiles.length) deletedContentFiles.push(EMPTY)
const testFiles: Array<string | symbol> = deletedContentFiles.length
? deletedContentFiles
: [EMPTY]
test.each(deletedContentFiles)('deleted-content: %s', async (file) => {
test.each(testFiles)('deleted-content: %s', async (file: string | symbol) => {
// Necessary because `test.each` will throw if the array is empty
if (file === EMPTY) return
@@ -111,20 +115,22 @@ describe('deleted-content', () => {
return path.join(p.basePath, p.relativePath) === file
})
if (page) {
throw new Error(`The supposedly deleted file ${file} is still in list of loaded pages`)
throw new Error(
`The supposedly deleted file ${file as string} is still in list of loaded pages`,
)
}
// You can't know what the possible permalinks were for a deleted page,
// because it's deleted so we can't look at its `versions` front matter.
// However, we always make sure all pages work in versionless.
const indexmdSuffixRegex = new RegExp(`${path.sep}index\\.md$`)
const mdSuffixRegex = /\.md$/
const relativePath = file.split(path.sep).slice(1).join(path.sep)
const relativePath = (file as string).split(path.sep).slice(1).join(path.sep)
const href = `/en/${relativePath.replace(indexmdSuffixRegex, '').replace(mdSuffixRegex, '')}`
const res = await head(href)
const error =
res.statusCode === 404
? `The deleted file ${file} did not set up a redirect when deleted.`
? `The deleted file ${file as string} did not set up a redirect when deleted.`
: ''
// Certain articles that are deleted and moved under a directory with the same article name
// should just route to the subcategory page instead of redirecting (docs content team confirmed).

View File

@@ -9,7 +9,8 @@ const supportedVersions = new Set(Object.keys(allVersions))
// Extracts the language code from the path
// if href is '/en/something', returns 'en'
export function getLangFromPath(href) {
export function getLangFromPath(href: string | undefined): string | null {
if (!href) return null
// first remove the version from the path so we don't match, say, `/free-pro-team` as `/fr/`
const match = getPathWithoutVersion(href).match(patterns.getLanguageCode)
return match ? match[1] : null
@@ -17,7 +18,8 @@ export function getLangFromPath(href) {
// Add the language to the given HREF
// /articles/foo -> /en/articles/foo
export function getPathWithLanguage(href, languageCode) {
export function getPathWithLanguage(href: string | undefined, languageCode: string): string {
if (!href) return `/${languageCode}`
return slash(path.posix.join('/', languageCode, getPathWithoutLanguage(href))).replace(
patterns.trailingSlash,
'$1',
@@ -26,12 +28,14 @@ export function getPathWithLanguage(href, languageCode) {
// Remove the language from the given HREF
// /en/articles/foo -> /articles/foo
export function getPathWithoutLanguage(href) {
export function getPathWithoutLanguage(href: string | undefined): string {
if (!href) return '/'
return slash(href.replace(patterns.hasLanguageCode, '/'))
}
// Remove the version segment from the path
export function getPathWithoutVersion(href) {
export function getPathWithoutVersion(href: string | undefined): string {
if (!href) return '/'
const versionFromPath = getVersionStringFromPath(href)
// If the derived version is not found in the list of all versions, just return the HREF
@@ -41,7 +45,16 @@ export function getPathWithoutVersion(href) {
}
// Return the version segment in a path
export function getVersionStringFromPath(href, supportedOnly = false) {
export function getVersionStringFromPath(
href: string | undefined,
supportedOnly: true,
): string | undefined
export function getVersionStringFromPath(href: string | undefined, supportedOnly?: false): string
export function getVersionStringFromPath(
href: string | undefined,
supportedOnly = false,
): string | undefined {
if (!href) return nonEnterpriseDefaultVersion
href = getPathWithoutLanguage(href)
// Some URLs don't ever have a version in the URL and it won't be found
@@ -90,15 +103,16 @@ export function getVersionStringFromPath(href, supportedOnly = false) {
}
// Return the corresponding object for the version segment in a path
export function getVersionObjectFromPath(href) {
const versionFromPath = getVersionStringFromPath(href)
export function getVersionObjectFromPath(href: string | undefined) {
const versionFromPath = getVersionStringFromPath(href, false)
return allVersions[versionFromPath]
}
// TODO needs refactoring + tests
// Return the product segment from the path
export function getProductStringFromPath(href) {
export function getProductStringFromPath(href: string | undefined): string {
if (!href) return 'homepage'
href = getPathWithoutLanguage(href)
if (href === '/') return 'homepage'
@@ -124,14 +138,15 @@ export function getProductStringFromPath(href) {
return productString
}
export function getCategoryStringFromPath(href) {
export function getCategoryStringFromPath(href: string | undefined): string | undefined {
if (!href) return undefined
href = getPathWithoutLanguage(href)
if (href === '/') return null
if (href === '/') return undefined
const pathParts = href.split('/')
if (pathParts.includes('early-access')) return null
if (pathParts.includes('early-access')) return undefined
const productIndex = productIds.includes(pathParts[2]) ? 2 : 1

View File

@@ -3,16 +3,22 @@ import path from 'path'
import http from 'http'
import { describe, expect, test } from 'vitest'
import type { Response } from 'express'
import Page from '@/frame/lib/page'
import findPage from '@/frame/middleware/find-page'
import type { ExtendedRequest } from '@/types'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function makeRequestResponse(url, currentVersion = 'free-pro-team@latest') {
const req = new http.IncomingMessage(null)
function makeRequestResponse(
url: string,
currentVersion = 'free-pro-team@latest',
): [ExtendedRequest, Response & { _status?: number; _message?: string }] {
const req = new http.IncomingMessage(null as any) as ExtendedRequest
req.method = 'GET'
req.url = url
// @ts-expect-error - path is read-only but we need to set it for testing
req.path = url
req.cookies = {}
req.headers = {}
@@ -23,14 +29,17 @@ function makeRequestResponse(url, currentVersion = 'free-pro-team@latest') {
req.context.currentVersion = currentVersion
req.context.pages = {}
const res = new http.ServerResponse(req)
res.status = function (code) {
const res = new http.ServerResponse(req) as Response & {
_status?: number
_message?: string
}
res.status = function (code: number) {
this._status = code
return {
send: function (message) {
send: (message: string) => {
this._message = message
}.bind(this),
}
},
} as any
}
return [req, res]
@@ -40,12 +49,15 @@ describe('find page middleware', () => {
test('attaches page on req.context', async () => {
const url = '/en/foo/bar'
const [req, res] = makeRequestResponse(url)
req.context.pages = {
'/en/foo/bar': await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
}),
const page = await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
if (page && req.context) {
req.context.pages = {
'/en/foo/bar': page as any,
}
}
let nextCalls = 0
@@ -53,7 +65,7 @@ describe('find page middleware', () => {
nextCalls++
})
expect(nextCalls).toBe(1)
expect(req.context.page).toBeInstanceOf(Page)
expect(req.context?.page).toBeInstanceOf(Page)
})
test('does not attach page on req.context if not found', async () => {
@@ -64,52 +76,61 @@ describe('find page middleware', () => {
nextCalls++
})
expect(nextCalls).toBe(1)
expect(req.context.page).toBe(undefined)
expect(req.context?.page).toBe(undefined)
})
test('re-reads from disk if in development mode', async () => {
const [req, res] = makeRequestResponse('/en/page-with-redirects')
req.context.pages = {
'/en/page-with-redirects': await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
}),
const page = await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
if (page && req.context) {
req.context.pages = {
'/en/page-with-redirects': page as any,
}
}
await findPage(req, res, () => {}, {
isDev: true,
contentRoot: path.join(__dirname, '../../../src/fixtures/fixtures'),
})
expect(req.context.page).toBeInstanceOf(Page)
expect(req.context?.page).toBeInstanceOf(Page)
})
test('finds it for non-fpt version URLS', async () => {
const [req, res] = makeRequestResponse('/en/page-with-redirects', 'enterprise-cloud@latest')
req.context.pages = {
'/en/page-with-redirects': await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
}),
const page = await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
if (page && req.context) {
req.context.pages = {
'/en/page-with-redirects': page as any,
}
}
await findPage(req, res, () => {}, {
isDev: true,
contentRoot: path.join(__dirname, '../../../src/fixtures/fixtures'),
})
expect(req.context.page).toBeInstanceOf(Page)
expect(req.context?.page).toBeInstanceOf(Page)
})
test("will 404 if the request version doesn't match the page", async () => {
// The 'versions:' frontmatter on 'page-with-redirects.md' does
// not include ghes. So this'll eventually 404.
const [req, res] = makeRequestResponse('/en/page-with-redirects', 'enterprise-server@latest')
req.context.pages = {
'/en/page-with-redirects': await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
}),
const page = await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
if (page && req.context) {
req.context.pages = {
'/en/page-with-redirects': page as any,
}
}
await findPage(req, res, () => {}, {
@@ -118,7 +139,7 @@ describe('find page middleware', () => {
})
expect(res._status).toBe(404)
expect(res._message).toMatch('')
expect(req.context.page).toBeUndefined()
expect(req.context?.page).toBeUndefined()
})
test('re-reads from disk if in development mode and finds nothing', async () => {
@@ -128,6 +149,6 @@ describe('find page middleware', () => {
isDev: true,
contentRoot: path.join(__dirname, '../../../src/fixtures/fixtures'),
})
expect(req.context.page).toBe(undefined)
expect(req.context?.page).toBe(undefined)
})
})

View File

@@ -10,6 +10,29 @@ import { getDocsVersion } from '@/versions/lib/all-versions'
import { REST_DATA_DIR, REST_SCHEMA_FILENAME } from '../../lib/index'
import { deprecated } from '@/versions/lib/enterprise-server-releases'
type RestVersions = {
[category: string]: {
[subcategory: string]: {
versions: string[]
}
}
}
type MarkdownUpdate = {
data: {
title: string
shortTitle: string
intro: string
versions: any
[key: string]: any
}
content: string
}
type MarkdownUpdates = {
[filepath: string]: MarkdownUpdate
}
const { frontmatterDefaults, targetDirectory, indexOrder } = JSON.parse(
await readFile('src/rest/lib/config.json', 'utf-8'),
)
@@ -32,7 +55,7 @@ export async function updateRestFiles() {
* @param {string} filePath - File path to parse
* @returns {string|null} - GHES version or null if not a GHES file
*/
export function getGHESVersionFromFilepath(filePath) {
export function getGHESVersionFromFilepath(filePath: string): string | null {
// Normalize path separators to handle both Unix and Windows paths
const normalizedPath = filePath.replace(/\\/g, '/')
const pathParts = normalizedPath.split('/')
@@ -49,7 +72,10 @@ export function getGHESVersionFromFilepath(filePath) {
// The data files are split up by version, so all files must be
// read to get a complete list of versions.
async function getDataFrontmatter(dataDirectory, schemaFilename) {
async function getDataFrontmatter(
dataDirectory: string,
schemaFilename: string,
): Promise<RestVersions> {
const fileList = walk(dataDirectory, { includeBasePath: true })
.filter((file) => path.basename(file) === schemaFilename)
// Ignore any deprecated versions. This allows us to stop supporting
@@ -67,7 +93,7 @@ async function getDataFrontmatter(dataDirectory, schemaFilename) {
return !deprecated.includes(ghesVersion)
})
const restVersions = {}
const restVersions: RestVersions = {}
for (const file of fileList) {
const data = JSON.parse(await readFile(file, 'utf-8'))
@@ -112,8 +138,8 @@ async function getDataFrontmatter(dataDirectory, schemaFilename) {
}
}
*/
async function getMarkdownContent(versions) {
const markdownUpdates = {}
async function getMarkdownContent(versions: RestVersions): Promise<MarkdownUpdates> {
const markdownUpdates: MarkdownUpdates = {}
for (const [category, subcategoryObject] of Object.entries(versions)) {
const subcategories = Object.keys(subcategoryObject)

View File

@@ -3,11 +3,12 @@ import {
shouldShowRequestContentType,
shouldShowResponseContentType,
generateExampleOptionTexts,
type CodeExample,
} from '@/rest/lib/code-example-utils'
describe('Request Content Type Logic', () => {
test('detects request content types differ correctly', () => {
const codeExamples = [
const codeExamples: CodeExample[] = [
{
description: 'Example',
request: { contentType: 'text/plain' },
@@ -25,7 +26,7 @@ describe('Request Content Type Logic', () => {
})
test('detects response content types differ correctly', () => {
const codeExamples = [
const codeExamples: CodeExample[] = [
{
description: 'JSON example',
request: { contentType: 'application/json' },
@@ -43,7 +44,7 @@ describe('Request Content Type Logic', () => {
})
test('generates correct options for markdown/raw scenario', () => {
const markdownRawExamples = [
const markdownRawExamples: CodeExample[] = [
{
description: 'Example',
request: {
@@ -70,7 +71,7 @@ describe('Request Content Type Logic', () => {
})
test('generates correct options when both request and response differ', () => {
const mixedExamples = [
const mixedExamples: CodeExample[] = [
{
description: 'JSON request',
request: {
@@ -100,7 +101,7 @@ describe('Request Content Type Logic', () => {
})
test('does not show content types when they are all the same', () => {
const sameContentTypeExamples = [
const sameContentTypeExamples: CodeExample[] = [
{
description: 'First example',
request: {
@@ -127,7 +128,7 @@ describe('Request Content Type Logic', () => {
})
test('handles single example correctly', () => {
const singleExample = [
const singleExample: CodeExample[] = [
{
description: 'Only example',
request: {