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

Migrate 6 files from JavaScript to TypeScript (#58002)

This commit is contained in:
Kevin Heis
2025-10-20 07:49:47 -07:00
committed by GitHub
parent 7cd719db8d
commit 5ae4ec0f5c
17 changed files with 354 additions and 225 deletions

View File

@@ -8,7 +8,7 @@ import { get } from '@/tests/helpers/e2etest'
// Type definitions for page objects // Type definitions for page objects
type Page = { type Page = {
autogenerated?: boolean autogenerated?: string
fullPath: string fullPath: string
permalinks: Array<{ href: string }> permalinks: Array<{ href: string }>
versions: { versions: {

View File

@@ -49,8 +49,8 @@ describe('front matter', () => {
async (page) => { async (page) => {
const redirectsContext = { redirects, pages } const redirectsContext = { redirects, pages }
const trouble = page.includeGuides const trouble = page
// Using any type for uri because includeGuides can contain various URI formats .includeGuides! // Using any type for uri because includeGuides can contain various URI formats
.map((uri: any, i: number) => checkURL(uri, i, redirectsContext)) .map((uri: any, i: number) => checkURL(uri, i, redirectsContext))
.filter(Boolean) .filter(Boolean)
@@ -58,14 +58,14 @@ describe('front matter', () => {
expect(trouble.length, customErrorMessage).toEqual(0) expect(trouble.length, customErrorMessage).toEqual(0)
const counts = new Map() const counts = new Map()
for (const guide of page.includeGuides) { for (const guide of page.includeGuides!) {
counts.set(guide, (counts.get(guide) || 0) + 1) counts.set(guide, (counts.get(guide) || 0) + 1)
} }
const countUnique = counts.size const countUnique = counts.size
let notDistinctMessage = `In ${page.relativePath} there are duplicate links in .includeGuides` let notDistinctMessage = `In ${page.relativePath} there are duplicate links in .includeGuides`
const dupes = [...counts.entries()].filter(([, count]) => count > 1).map(([entry]) => entry) const dupes = [...counts.entries()].filter(([, count]) => count > 1).map(([entry]) => entry)
notDistinctMessage += `\nTo fix this, remove: ${dupes.join(' and ')}` notDistinctMessage += `\nTo fix this, remove: ${dupes.join(' and ')}`
expect(page.includeGuides.length, notDistinctMessage).toEqual(countUnique) expect(page.includeGuides!.length, notDistinctMessage).toEqual(countUnique)
}, },
) )
@@ -78,12 +78,17 @@ describe('front matter', () => {
const redirectsContext = { redirects, pages } const redirectsContext = { redirects, pages }
const trouble = [] const trouble = []
for (const links of Object.values(page.featuredLinks)) { for (const links of Object.values(page.featuredLinks!)) {
// Some thing in `.featuredLinks` are not arrays. // Some thing in `.featuredLinks` are not arrays.
// For example `popularHeading`. So just skip them. // For example `popularHeading`. So just skip them.
if (!Array.isArray(links)) continue if (!Array.isArray(links)) continue
trouble.push(...links.map((uri, i) => checkURL(uri, i, redirectsContext)).filter(Boolean)) trouble.push(
...links
.filter((link) => link.href)
.map((link, i) => checkURL(link.href, i, redirectsContext))
.filter(Boolean),
)
} }
const customErrorMessage = makeCustomErrorMessage(page, trouble, 'featuredLinks') const customErrorMessage = makeCustomErrorMessage(page, trouble, 'featuredLinks')
@@ -98,7 +103,7 @@ describe('front matter', () => {
const redirectsContext = { redirects, pages } const redirectsContext = { redirects, pages }
const trouble = [] const trouble = []
for (const linksRaw of Object.values(page.introLinks)) { for (const linksRaw of Object.values(page.introLinks!)) {
const links = Array.isArray(linksRaw) ? linksRaw : [linksRaw] const links = Array.isArray(linksRaw) ? linksRaw : [linksRaw]
trouble.push( trouble.push(
...links ...links

View File

@@ -8,6 +8,14 @@ import { merge, get } from 'lodash-es'
import languages from '@/languages/lib/languages' import languages from '@/languages/lib/languages'
import { correctTranslatedContentStrings } from '@/languages/lib/correct-translation-content' import { correctTranslatedContentStrings } from '@/languages/lib/correct-translation-content'
interface YAMLException extends Error {
mark?: any
}
interface FileSystemError extends Error {
code?: string
}
// If you run `export DEBUG_JIT_DATA_READS=true` in your terminal, // If you run `export DEBUG_JIT_DATA_READS=true` in your terminal,
// next time it will mention every file it reads from disk. // next time it will mention every file it reads from disk.
const DEBUG_JIT_DATA_READS = Boolean(JSON.parse(process.env.DEBUG_JIT_DATA_READS || 'false')) const DEBUG_JIT_DATA_READS = Boolean(JSON.parse(process.env.DEBUG_JIT_DATA_READS || 'false'))
@@ -23,29 +31,33 @@ const ALWAYS_ENGLISH_MD_FILES = new Set([
]) ])
// Returns all the things inside a directory // Returns all the things inside a directory
export const getDeepDataByLanguage = memoize((dottedPath, langCode, dir = null) => { export const getDeepDataByLanguage = memoize(
if (!(langCode in languages)) (dottedPath: string, langCode: string, dir: string | null = null): any => {
throw new Error(`langCode '${langCode}' not a recognized language code`) if (!(langCode in languages)) {
throw new Error(`langCode '${langCode}' not a recognized language code`)
}
// The `dir` argument is only used for testing purposes. // The `dir` argument is only used for testing purposes.
// For example, our unit tests that depend on using a fixtures // For example, our unit tests that depend on using a fixtures
// root. // root.
// If we don't allow those tests to override the `dir` argument, // If we don't allow those tests to override the `dir` argument,
// it'll be stuck from the first time `languages.js` was imported. // it'll be stuck from the first time `languages.js` was imported.
if (dir === null) { let actualDir = dir
dir = languages[langCode].dir if (actualDir === null) {
} actualDir = languages[langCode].dir
return getDeepDataByDir(dottedPath, dir) }
}) return getDeepDataByDir(dottedPath, actualDir)
},
)
// Doesn't need to be memoized because it's used by getDataKeysByLanguage // Doesn't need to be memoized because it's used by getDataKeysByLanguage
// which is already memoized. // which is already memoized.
function getDeepDataByDir(dottedPath, dir) { function getDeepDataByDir(dottedPath: string, dir: string): any {
const fullPath = ['data'] const fullPath = ['data']
const split = dottedPath.split(/\./g) const split = dottedPath.split(/\./g)
fullPath.push(...split) fullPath.push(...split)
const things = {} const things: any = {}
const relPath = fullPath.join(path.sep) const relPath = fullPath.join(path.sep)
for (const dirent of getDirents(dir, relPath)) { for (const dirent of getDirents(dir, relPath)) {
if (dirent.name === 'README.md') continue if (dirent.name === 'README.md') continue
@@ -63,12 +75,12 @@ function getDeepDataByDir(dottedPath, dir) {
return things return things
} }
function getDirents(root, relPath) { function getDirents(root: string, relPath: string): fs.Dirent[] {
const filePath = root ? path.join(root, relPath) : relPath const filePath = root ? path.join(root, relPath) : relPath
return fs.readdirSync(filePath, { withFileTypes: true }) return fs.readdirSync(filePath, { withFileTypes: true })
} }
export const getUIDataMerged = memoize((langCode) => { export const getUIDataMerged = memoize((langCode: string): any => {
const uiEnglish = getUIData('en') const uiEnglish = getUIData('en')
if (langCode === 'en') return uiEnglish if (langCode === 'en') return uiEnglish
// Got to combine. Start with the English and put the translation on top. // Got to combine. Start with the English and put the translation on top.
@@ -77,7 +89,7 @@ export const getUIDataMerged = memoize((langCode) => {
// swedish = {food: "Mat"} // swedish = {food: "Mat"}
// => // =>
// combind = {food: "Mat", drink: "Drink"} // combind = {food: "Mat", drink: "Drink"}
const combined = {} const combined: any = {}
merge(combined, uiEnglish) merge(combined, uiEnglish)
merge(combined, getUIData(langCode)) merge(combined, getUIData(langCode))
return combined return combined
@@ -85,13 +97,13 @@ export const getUIDataMerged = memoize((langCode) => {
// Doesn't need to be memoized because it's used by another function // Doesn't need to be memoized because it's used by another function
// that is memoized. // that is memoized.
const getUIData = (langCode) => { const getUIData = (langCode: string): any => {
const fullPath = ['data', 'ui.yml'] const fullPath = ['data', 'ui.yml']
const { dir } = languages[langCode] const { dir } = languages[langCode]
return getYamlContent(dir, fullPath.join(path.sep)) return getYamlContent(dir, fullPath.join(path.sep))
} }
export const getDataByLanguage = memoize((dottedPath, langCode) => { export const getDataByLanguage = memoize((dottedPath: string, langCode: string): any => {
if (!(langCode in languages)) if (!(langCode in languages))
throw new Error(`langCode '${langCode}' not a recognized language code`) throw new Error(`langCode '${langCode}' not a recognized language code`)
const { dir } = languages[langCode] const { dir } = languages[langCode]
@@ -111,7 +123,7 @@ export const getDataByLanguage = memoize((dottedPath, langCode) => {
} }
return value return value
} catch (error) { } catch (error) {
if (error instanceof Error && error.mark && error.message) { if (error instanceof Error && (error as YAMLException).mark && error.message) {
// It's a yaml.load() generated error! // It's a yaml.load() generated error!
// Remember, the file that we read might have been a .yml or a .md // Remember, the file that we read might have been a .yml or a .md
// file. If it was a .md file, with corrupt front-matter that too // file. If it was a .md file, with corrupt front-matter that too
@@ -128,12 +140,17 @@ export const getDataByLanguage = memoize((dottedPath, langCode) => {
throw error throw error
} }
if (error.code === 'ENOENT') return undefined if ((error as FileSystemError).code === 'ENOENT') return undefined
throw error throw error
} }
}) })
function getDataByDir(dottedPath, dir, englishRoot, langCode) { function getDataByDir(
dottedPath: string,
dir: string,
englishRoot?: string,
langCode?: string,
): any {
const fullPath = ['data'] const fullPath = ['data']
// Using English here because it doesn't matter. We just want to // Using English here because it doesn't matter. We just want to
@@ -159,17 +176,17 @@ function getDataByDir(dottedPath, dir, englishRoot, langCode) {
// data/early-access/reusables/foo/bar.md // data/early-access/reusables/foo/bar.md
// //
if (split[0] === 'early-access') { if (split[0] === 'early-access') {
fullPath.push(split.shift()) fullPath.push(split.shift()!)
} }
const first = split[0] const first = split[0]
if (first === 'variables') { if (first === 'variables') {
const key = split.pop() const key = split.pop()!
const basename = split.pop() const basename = split.pop()!
fullPath.push(...split) fullPath.push(...split)
fullPath.push(`${basename}.yml`) fullPath.push(`${basename}.yml`)
const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot) const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot)
if (allData) { if (allData && key) {
const value = allData[key] const value = allData[key]
if (value) { if (value) {
return matter(value).content return matter(value).content
@@ -177,11 +194,11 @@ function getDataByDir(dottedPath, dir, englishRoot, langCode) {
} else { } else {
console.warn(`Unable to find variables Yaml file ${fullPath.join(path.sep)}`) console.warn(`Unable to find variables Yaml file ${fullPath.join(path.sep)}`)
} }
return return undefined
} }
if (first === 'reusables') { if (first === 'reusables') {
const nakedname = split.pop() const nakedname = split.pop()!
fullPath.push(...split) fullPath.push(...split)
fullPath.push(`${nakedname}.md`) fullPath.push(`${nakedname}.md`)
const markdown = getMarkdownContent(dir, fullPath.join(path.sep), englishRoot) const markdown = getMarkdownContent(dir, fullPath.join(path.sep), englishRoot)
@@ -205,7 +222,7 @@ function getDataByDir(dottedPath, dir, englishRoot, langCode) {
// genuinely give it the English equivalent content, which it // genuinely give it the English equivalent content, which it
// sometimes uses to correct some Liquid tags. At least other // sometimes uses to correct some Liquid tags. At least other
// good corrections might happen. // good corrections might happen.
if (error.code !== 'ENOENT') { if ((error as FileSystemError).code !== 'ENOENT') {
throw error throw error
} }
} }
@@ -226,25 +243,25 @@ function getDataByDir(dottedPath, dir, englishRoot, langCode) {
} }
if (first === 'product-examples' || first === 'glossaries' || first === 'release-notes') { if (first === 'product-examples' || first === 'glossaries' || first === 'release-notes') {
const basename = split.pop() const basename = split.pop()!
fullPath.push(...split) fullPath.push(...split)
fullPath.push(`${basename}.yml`) fullPath.push(`${basename}.yml`)
return getYamlContent(dir, fullPath.join(path.sep), englishRoot) return getYamlContent(dir, fullPath.join(path.sep), englishRoot)
} }
if (first === 'learning-tracks') { if (first === 'learning-tracks') {
const key = split.pop() const key = split.pop()!
const basename = split.pop() const basename = split.pop()!
fullPath.push(...split) fullPath.push(...split)
fullPath.push(`${basename}.yml`) fullPath.push(`${basename}.yml`)
const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot) const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot)
return allData[key] return key ? allData[key] : undefined
} }
throw new Error(`Can't find the key '${dottedPath}' in the scope.`) throw new Error(`Can't find the key '${dottedPath}' in the scope.`)
} }
function getSmartSplit(dottedPath) { function getSmartSplit(dottedPath: string): string[] {
const split = dottedPath.split('.') const split = dottedPath.split('.')
const bits = [] const bits = []
for (let i = 0, len = split.length; i < len; i++) { for (let i = 0, len = split.length; i < len; i++) {
@@ -284,36 +301,44 @@ function getSmartSplit(dottedPath) {
// 2.1. read and parse data/variables/product.yml // 2.1. read and parse data/variables/product.yml
// -> cache HIT (Yay!) // -> cache HIT (Yay!)
// //
const getYamlContent = memoize((root, relPath, englishRoot) => { const getYamlContent = memoize(
// Certain Yaml files we know we always want the English one (root: string | undefined, relPath: string, englishRoot?: string): any => {
// no matter what the specified language is. // Certain Yaml files we know we always want the English one
// For example, we never want `data/variables/product.yml` translated // no matter what the specified language is.
// so we know to immediately fall back to the English one. // For example, we never want `data/variables/product.yml` translated
if (ALWAYS_ENGLISH_YAML_FILES.has(relPath)) { // so we know to immediately fall back to the English one.
// This forces it to read from English. Later, when it goes if (ALWAYS_ENGLISH_YAML_FILES.has(relPath)) {
// into `getFileContent(...)` it will note that `root !== englishRoot` // This forces it to read from English. Later, when it goes
// so it won't try to fall back. // into `getFileContent(...)` it will note that `root !== englishRoot`
root = englishRoot // so it won't try to fall back.
} root = englishRoot
const fileContent = getFileContent(root, relPath, englishRoot) }
return yaml.load(fileContent, { filename: relPath }) const fileContent = getFileContent(root, relPath, englishRoot)
}) return yaml.load(fileContent, { filename: relPath })
},
)
// The reason why this is memoized, is the same as for getYamlContent() above. // The reason why this is memoized, is the same as for getYamlContent() above.
const getMarkdownContent = memoize((root, relPath, englishRoot) => { const getMarkdownContent = memoize(
// Certain reusables we never want to be pulled from the translations. (root: string | undefined, relPath: string, englishRoot?: string): string => {
// For example, certain reusables don't contain any English prose. Just // Certain reusables we never want to be pulled from the translations.
// facts like numbers or hardcoded key words. // For example, certain reusables don't contain any English prose. Just
// If this is the case, forcibly always draw from the English files. // facts like numbers or hardcoded key words.
if (ALWAYS_ENGLISH_MD_FILES.has(relPath)) { // If this is the case, forcibly always draw from the English files.
root = englishRoot if (ALWAYS_ENGLISH_MD_FILES.has(relPath)) {
} root = englishRoot
}
const fileContent = getFileContent(root, relPath, englishRoot) const fileContent = getFileContent(root, relPath, englishRoot)
return matter(fileContent).content.trimEnd() return matter(fileContent).content.trimEnd()
}) },
)
const getFileContent = (root, relPath, englishRoot) => { const getFileContent = (
root: string | undefined,
relPath: string,
englishRoot?: string,
): string => {
const filePath = root ? path.join(root, relPath) : relPath const filePath = root ? path.join(root, relPath) : relPath
if (DEBUG_JIT_DATA_READS) console.log('READ', filePath) if (DEBUG_JIT_DATA_READS) console.log('READ', filePath)
try { try {
@@ -321,10 +346,10 @@ const getFileContent = (root, relPath, englishRoot) => {
} catch (err) { } catch (err) {
// It might fail because that particular data entry doesn't yet // It might fail because that particular data entry doesn't yet
// exist in a translation // exist in a translation
if (err.code === 'ENOENT') { if ((err as FileSystemError).code === 'ENOENT') {
// If looking it up as a file fails, give it one more chance if the // If looking it up as a file fails, give it one more chance if the
// read was for a translation. // read was for a translation.
if (root !== englishRoot) { if (englishRoot && root !== englishRoot) {
// We can try again but this time using the English files // We can try again but this time using the English files
return getFileContent(englishRoot, relPath, englishRoot) return getFileContent(englishRoot, relPath, englishRoot)
} }
@@ -333,9 +358,9 @@ const getFileContent = (root, relPath, englishRoot) => {
} }
} }
function memoize(func) { function memoize<T extends (...args: any[]) => any>(func: T): T {
const cache = new Map() const cache = new Map<string, any>()
return (...args) => { return ((...args: any[]) => {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
// It is very possible that certain files, when caching is disabled, // It is very possible that certain files, when caching is disabled,
// are read multiple times in short succession. E.g. `product.yml`. // are read multiple times in short succession. E.g. `product.yml`.
@@ -374,5 +399,5 @@ function memoize(func) {
if (Array.isArray(value)) return [...value] if (Array.isArray(value)) return [...value]
if (typeof value === 'object') return { ...value } if (typeof value === 'object') return { ...value }
return value return value
} }) as T
} }

View File

@@ -12,7 +12,7 @@ import {
import { DataDirectory } from '@/tests/helpers/data-directory' import { DataDirectory } from '@/tests/helpers/data-directory'
describe('get-data', () => { describe('get-data', () => {
let dd let dd: DataDirectory
const enDirBefore = languages.en.dir const enDirBefore = languages.en.dir
// Only `en` is available in tests, so pretend we also have Japanese // Only `en` is available in tests, so pretend we also have Japanese
languages.ja = Object.assign({}, languages.en, {}) languages.ja = Object.assign({}, languages.en, {})
@@ -219,7 +219,7 @@ front: >'matter
`.trim() `.trim()
describe('get-data on corrupt translations', () => { describe('get-data on corrupt translations', () => {
let dd let dd: DataDirectory
const enDirBefore = languages.en.dir const enDirBefore = languages.en.dir
// Only `en` is available in vitest tests, so pretend we also have Japanese // Only `en` is available in vitest tests, so pretend we also have Japanese
languages.ja = Object.assign({}, languages.en, {}) languages.ja = Object.assign({}, languages.en, {})

View File

@@ -6,6 +6,32 @@ import { allVersions } from '@/versions/lib/all-versions'
import { allTools } from '@/tools/lib/all-tools' import { allTools } from '@/tools/lib/all-tools'
import { getDeepDataByLanguage } from '@/data-directory/lib/get-data' import { getDeepDataByLanguage } from '@/data-directory/lib/get-data'
interface SchemaProperty {
type?: string | string[]
translatable?: boolean
deprecated?: boolean
default?: any
minimum?: number
maximum?: number
enum?: any[]
errorMessage?: string
items?: any
properties?: Record<string, any>
required?: string[]
additionalProperties?: boolean
format?: string
description?: string
minItems?: number
maxItems?: number
}
interface Schema {
type: string
required: string[]
additionalProperties: boolean
properties: Record<string, SchemaProperty>
}
const layoutNames = [ const layoutNames = [
'default', 'default',
'graphql-explorer', 'graphql-explorer',
@@ -38,7 +64,7 @@ export const contentTypesEnum = [
'other', // Everything else. 'other', // Everything else.
] ]
export const schema = { export const schema: Schema = {
type: 'object', type: 'object',
required: ['title', 'versions'], required: ['title', 'versions'],
additionalProperties: false, additionalProperties: false,
@@ -383,8 +409,8 @@ export const schema = {
} }
// returns a list of deprecated properties // returns a list of deprecated properties
export const deprecatedProperties = Object.keys(schema.properties).filter((prop) => { export const deprecatedProperties = Object.keys(schema.properties).filter((prop: string) => {
return schema.properties[prop].deprecated return (schema.properties as Record<string, SchemaProperty>)[prop].deprecated
}) })
const featureVersionsProp = { const featureVersionsProp = {
@@ -407,17 +433,17 @@ const semverRange = {
errorMessage: 'Must be a valid SemVer range: ${0}', errorMessage: 'Must be a valid SemVer range: ${0}',
} }
schema.properties.versions = { ;(schema.properties as Record<string, any>).versions = {
type: ['object', 'string'], // allow a '*' string to indicate all versions type: ['object', 'string'], // allow a '*' string to indicate all versions
additionalProperties: false, // don't allow any versions in FM that aren't defined in lib/all-versions additionalProperties: false, // don't allow any versions in FM that aren't defined in lib/all-versions
properties: Object.values(allVersions).reduce((acc, versionObj) => { properties: Object.values(allVersions).reduce((acc: any, versionObj) => {
acc[versionObj.plan] = semverRange acc[versionObj.plan] = semverRange
acc[versionObj.shortName] = semverRange acc[versionObj.shortName] = semverRange
return acc return acc
}, featureVersionsProp), }, featureVersionsProp),
} }
export function frontmatter(markdown, opts = {}) { export function frontmatter(markdown: string, opts: any = {}) {
const defaults = { const defaults = {
schema, schema,
} }

View File

@@ -1,5 +1,8 @@
import path from 'path' import path from 'path'
import type { Language } from '@/languages/lib/languages'
import type { UnversionedTree, UnversionLanguageTree, SiteTree, Tree } from '@/types'
import languages from '@/languages/lib/languages' import languages from '@/languages/lib/languages'
import { allVersions } from '@/versions/lib/all-versions' import { allVersions } from '@/versions/lib/all-versions'
import createTree from './create-tree' import createTree from './create-tree'
@@ -10,6 +13,10 @@ import Permalink from './permalink'
import frontmatterSchema from './frontmatter' import frontmatterSchema from './frontmatter'
import { correctTranslatedContentStrings } from '@/languages/lib/correct-translation-content' import { correctTranslatedContentStrings } from '@/languages/lib/correct-translation-content'
interface FileSystemError extends Error {
code?: string
}
// If you run `export DEBUG_TRANSLATION_FALLBACKS=true` in your terminal, // If you run `export DEBUG_TRANSLATION_FALLBACKS=true` in your terminal,
// every time a translation file fails to initialize we fall back to English // every time a translation file fails to initialize we fall back to English
// and write a warning to stdout. // and write a warning to stdout.
@@ -29,7 +36,7 @@ class FrontmatterParsingError extends Error {}
// Note! As of Nov 2022, the schema says that 'product' is translatable // Note! As of Nov 2022, the schema says that 'product' is translatable
// which is surprising since only a single page has prose in it. // which is surprising since only a single page has prose in it.
const translatableFrontmatterKeys = Object.entries(frontmatterSchema.schema.properties) const translatableFrontmatterKeys = Object.entries(frontmatterSchema.schema.properties)
.filter(([, value]) => value.translatable) .filter(([, value]: [string, any]) => value.translatable)
.map(([key]) => key) .map(([key]) => key)
/** /**
@@ -37,13 +44,18 @@ const translatableFrontmatterKeys = Object.entries(frontmatterSchema.schema.prop
* first since it's the most expensive work. This gets us a nested object with pages attached that we can use * first since it's the most expensive work. This gets us a nested object with pages attached that we can use
* as the basis for the siteTree after we do some versioning. We can also use it to derive the pageList. * as the basis for the siteTree after we do some versioning. We can also use it to derive the pageList.
*/ */
export async function loadUnversionedTree(languagesOnly = []) { export async function loadUnversionedTree(
languagesOnly: string[] = [],
): Promise<UnversionLanguageTree> {
if (languagesOnly && !Array.isArray(languagesOnly)) { if (languagesOnly && !Array.isArray(languagesOnly)) {
throw new Error("'languagesOnly' has to be an array") throw new Error("'languagesOnly' has to be an array")
} }
const unversionedTree = {} const unversionedTree: UnversionLanguageTree = {} as UnversionLanguageTree
unversionedTree.en = await createTree(path.join(languages.en.dir, 'content')) const enTree = await createTree(path.join(languages.en.dir, 'content'))
setCategoryApplicableVersions(unversionedTree.en) if (enTree) {
unversionedTree.en = enTree
setCategoryApplicableVersions(unversionedTree.en)
}
const languagesValues = Object.entries(languages) const languagesValues = Object.entries(languages)
.filter(([language]) => { .filter(([language]) => {
@@ -70,14 +82,14 @@ export async function loadUnversionedTree(languagesOnly = []) {
return unversionedTree return unversionedTree
} }
function setCategoryApplicableVersions(tree) { function setCategoryApplicableVersions(tree: UnversionedTree): void {
// Now that the tree has been fully computed, we can for any node that // Now that the tree has been fully computed, we can for any node that
// is a category page, re-set its `.applicableVersions` and `.permalinks` // is a category page, re-set its `.applicableVersions` and `.permalinks`
// based on the union set of all its immediate children's // based on the union set of all its immediate children's
// `.applicableVersions`. // `.applicableVersions`.
for (const childPage of tree.childPages) { for (const childPage of tree.childPages) {
if (childPage.page.relativePath.endsWith('index.md')) { if (childPage.page.relativePath.endsWith('index.md')) {
const combinedApplicableVersions = [] const combinedApplicableVersions: string[] = []
let moreThanOneChild = false let moreThanOneChild = false
for (const childChildPage of childPage.childPages || []) { for (const childChildPage of childPage.childPages || []) {
for (const version of childChildPage.page.applicableVersions) { for (const version of childChildPage.page.applicableVersions) {
@@ -112,12 +124,16 @@ function setCategoryApplicableVersions(tree) {
} }
} }
function equalSets(setA, setB) { function equalSets(setA: Set<string>, setB: Set<string>): boolean {
return setA.size === setB.size && [...setA].every((x) => setB.has(x)) return setA.size === setB.size && [...setA].every((x) => setB.has(x))
} }
async function translateTree(dir, langObj, enTree) { async function translateTree(
const item = {} dir: string,
langObj: Language,
enTree: UnversionedTree,
): Promise<UnversionedTree> {
const item: Partial<UnversionedTree> = {}
const enPage = enTree.page const enPage = enTree.page
const { ...enData } = enPage const { ...enData } = enPage
@@ -170,13 +186,14 @@ async function translateTree(dir, langObj, enTree) {
if (THROW_TRANSLATION_ERRORS) { if (THROW_TRANSLATION_ERRORS) {
throw new Error(message) throw new Error(message)
} }
data[property] = enData[property] // Using any because the property is dynamic
;(data as any)[property] = (enData as any)[property]
} }
} }
} catch (error) { } catch (error) {
// If it didn't work because it didn't exist, don't fret, // If it didn't work because it didn't exist, don't fret,
// we'll use the English equivalent's data and content. // we'll use the English equivalent's data and content.
if (error.code === 'ENOENT' || error instanceof FrontmatterParsingError) { if ((error as FileSystemError).code === 'ENOENT' || error instanceof FrontmatterParsingError) {
data = enData data = enData
content = enPage.markdown content = enPage.markdown
const message = `Unable to initialize ${fullPath} because translation content file does not exist.` const message = `Unable to initialize ${fullPath} because translation content file does not exist.`
@@ -204,14 +221,14 @@ async function translateTree(dir, langObj, enTree) {
code: langObj.code, code: langObj.code,
}) })
translatedData.title = correctTranslatedContentStrings(translatedData.title, enPage.title, { translatedData.title = correctTranslatedContentStrings(translatedData.title || '', enPage.title, {
relativePath, relativePath,
code: langObj.code, code: langObj.code,
}) })
if (translatedData.shortTitle) { if (translatedData.shortTitle) {
translatedData.shortTitle = correctTranslatedContentStrings( translatedData.shortTitle = correctTranslatedContentStrings(
translatedData.shortTitle, translatedData.shortTitle,
enPage.shortTitle, enPage.shortTitle || '',
{ {
relativePath, relativePath,
code: langObj.code, code: langObj.code,
@@ -225,7 +242,8 @@ async function translateTree(dir, langObj, enTree) {
}) })
} }
item.page = new Page( // Using any to handle the complex object merging for Page constructor
;(item as UnversionedTree).page = new Page(
Object.assign( Object.assign(
{}, {},
// By default, shallow-copy everything from the English equivalent. // By default, shallow-copy everything from the English equivalent.
@@ -239,20 +257,23 @@ async function translateTree(dir, langObj, enTree) {
}, },
// And the translations translated properties. // And the translations translated properties.
translatedData, translatedData,
), ) as any,
) ) as any
if (item.page.children) { if (
item.childPages = await Promise.all( ((item as UnversionedTree).page as any).children &&
((item as UnversionedTree).page as any).children.length > 0
) {
;(item as UnversionedTree).childPages = await Promise.all(
enTree.childPages enTree.childPages
.filter((childTree) => { .filter((childTree: UnversionedTree) => {
// Translations should not get early access pages at all. // Translations should not get early access pages at all.
return childTree.page.relativePath.split(path.sep)[0] !== 'early-access' return childTree.page.relativePath.split(path.sep)[0] !== 'early-access'
}) })
.map((childTree) => translateTree(dir, langObj, childTree)), .map((childTree: UnversionedTree) => translateTree(dir, langObj, childTree)),
) )
} }
return item return item as UnversionedTree
} }
/** /**
@@ -267,9 +288,12 @@ async function translateTree(dir, langObj, enTree) {
* *
* Order of languages and versions doesn't matter, but order of child page arrays DOES matter (for navigation). * Order of languages and versions doesn't matter, but order of child page arrays DOES matter (for navigation).
*/ */
export async function loadSiteTree(unversionedTree, languagesOnly = []) { export async function loadSiteTree(
unversionedTree?: UnversionLanguageTree,
languagesOnly: string[] = [],
): Promise<SiteTree> {
const rawTree = Object.assign({}, unversionedTree || (await loadUnversionedTree(languagesOnly))) const rawTree = Object.assign({}, unversionedTree || (await loadUnversionedTree(languagesOnly)))
const siteTree = {} const siteTree: SiteTree = {}
const langCodes = (languagesOnly.length && languagesOnly) || Object.keys(languages) const langCodes = (languagesOnly.length && languagesOnly) || Object.keys(languages)
// For every language... // For every language...
@@ -278,7 +302,7 @@ export async function loadSiteTree(unversionedTree, languagesOnly = []) {
if (!(langCode in rawTree)) { if (!(langCode in rawTree)) {
throw new Error(`No tree for language ${langCode}`) throw new Error(`No tree for language ${langCode}`)
} }
const treePerVersion = {} const treePerVersion: { [version: string]: Tree } = {}
// in every version... // in every version...
await Promise.all( await Promise.all(
versions.map(async (version) => { versions.map(async (version) => {
@@ -298,10 +322,10 @@ export async function loadSiteTree(unversionedTree, languagesOnly = []) {
return siteTree return siteTree
} }
export async function versionPages(obj, version, langCode) { export async function versionPages(obj: any, version: string, langCode: string): Promise<Tree> {
// Add a versioned href as a convenience for use in layouts. // Add a versioned href as a convenience for use in layouts.
const permalink = obj.page.permalinks.find( const permalink = obj.page.permalinks.find(
(pl) => (pl: any) =>
pl.pageVersion === version || pl.pageVersion === version ||
(pl.pageVersion === 'homepage' && version === nonEnterpriseDefaultVersion), (pl.pageVersion === 'homepage' && version === nonEnterpriseDefaultVersion),
) )
@@ -316,9 +340,9 @@ export async function versionPages(obj, version, langCode) {
const versionedChildPages = await Promise.all( const versionedChildPages = await Promise.all(
obj.childPages obj.childPages
// Drop child pages that do not apply to the current version // Drop child pages that do not apply to the current version
.filter((childPage) => childPage.page.applicableVersions.includes(version)) .filter((childPage: any) => childPage.page.applicableVersions.includes(version))
// Version the child pages recursively. // Version the child pages recursively.
.map((childPage) => versionPages(Object.assign({}, childPage), version, langCode)), .map((childPage: any) => versionPages(Object.assign({}, childPage), version, langCode)),
) )
obj.childPages = [...versionedChildPages] obj.childPages = [...versionedChildPages]
@@ -327,12 +351,15 @@ export async function versionPages(obj, version, langCode) {
} }
// Derive a flat array of Page objects in all languages. // Derive a flat array of Page objects in all languages.
export async function loadPageList(unversionedTree, languagesOnly = []) { export async function loadPageList(
unversionedTree?: UnversionLanguageTree,
languagesOnly: string[] = [],
): Promise<Page[]> {
if (languagesOnly && !Array.isArray(languagesOnly)) { if (languagesOnly && !Array.isArray(languagesOnly)) {
throw new Error("'languagesOnly' has to be an array") throw new Error("'languagesOnly' has to be an array")
} }
const rawTree = unversionedTree || (await loadUnversionedTree(languagesOnly)) const rawTree = unversionedTree || (await loadUnversionedTree(languagesOnly))
const pageList = [] const pageList: Page[] = []
const langCodes = (languagesOnly.length && languagesOnly) || Object.keys(languages) const langCodes = (languagesOnly.length && languagesOnly) || Object.keys(languages)
await Promise.all( await Promise.all(
@@ -344,13 +371,15 @@ export async function loadPageList(unversionedTree, languagesOnly = []) {
}), }),
) )
async function addToCollection(item, collection) { async function addToCollection(item: UnversionedTree, collection: Page[]): Promise<void> {
if (!item.page) return if (!item.page) return
collection.push(item.page) collection.push(item.page as any)
if (!item.childPages) return if (!item.childPages) return
await Promise.all( await Promise.all(
item.childPages.map(async (childPage) => await addToCollection(childPage, collection)), item.childPages.map(
async (childPage: UnversionedTree) => await addToCollection(childPage, collection),
),
) )
} }
@@ -360,19 +389,25 @@ export async function loadPageList(unversionedTree, languagesOnly = []) {
export const loadPages = loadPageList export const loadPages = loadPageList
// Create an object from the list of all pages with permalinks as keys for fast lookup. // Create an object from the list of all pages with permalinks as keys for fast lookup.
export function createMapFromArray(pageList) { export function createMapFromArray(pageList: Page[]): Record<string, Page> {
const pageMap = pageList.reduce((pageMap, page) => { const pageMap = pageList.reduce(
for (const permalink of page.permalinks) { (pageMap: Record<string, Page>, page: Page) => {
pageMap[permalink.href] = page for (const permalink of page.permalinks) {
} pageMap[permalink.href] = page
return pageMap }
}, {}) return pageMap
},
{} as Record<string, Page>,
)
return pageMap return pageMap
} }
export async function loadPageMap(pageList, languagesOnly = []) { export async function loadPageMap(
const pages = pageList || (await loadPageList(languagesOnly)) pageList?: Page[],
languagesOnly: string[] = [],
): Promise<Record<string, Page>> {
const pages = pageList || (await loadPageList(undefined, languagesOnly))
const pageMap = createMapFromArray(pages) const pageMap = createMapFromArray(pages)
return pageMap return pageMap
} }

View File

@@ -1,5 +1,6 @@
import assert from 'assert' import assert from 'assert'
import path from 'path' import path from 'path'
import fs from 'fs/promises'
import cheerio from 'cheerio' import cheerio from 'cheerio'
import getApplicableVersions from '@/versions/lib/get-applicable-versions' import getApplicableVersions from '@/versions/lib/get-applicable-versions'
import generateRedirectsForPermalinks from '@/redirects/lib/permalinks' import generateRedirectsForPermalinks from '@/redirects/lib/permalinks'
@@ -19,7 +20,7 @@ import { renderContentWithFallback } from '@/languages/lib/render-with-fallback'
import { deprecated, supported } from '@/versions/lib/enterprise-server-releases' import { deprecated, supported } from '@/versions/lib/enterprise-server-releases'
import { allPlatforms } from '@/tools/lib/all-platforms' import { allPlatforms } from '@/tools/lib/all-platforms'
import type { Context, FrontmatterVersions } from '@/types' import type { Context, FrontmatterVersions, FeaturedLinksExpanded } from '@/types'
// We're going to check a lot of pages' "ID" (the first part of // We're going to check a lot of pages' "ID" (the first part of
// the relativePath) against `productMap` to make sure it's valid. // the relativePath) against `productMap` to make sure it's valid.
@@ -97,6 +98,8 @@ class Page {
public rawIntroLinks?: Record<string, string> public rawIntroLinks?: Record<string, string>
public recommended?: string[] public recommended?: string[]
public rawRecommended?: string[] public rawRecommended?: string[]
public autogenerated?: string
public featuredLinks?: FeaturedLinksExpanded
// Derived properties // Derived properties
public languageCode!: string public languageCode!: string
@@ -104,6 +107,7 @@ class Page {
public basePath!: string public basePath!: string
public fullPath!: string public fullPath!: string
public markdown!: string public markdown!: string
public mtime!: number
public documentType: string public documentType: string
public applicableVersions: string[] public applicableVersions: string[]
public permalinks: Permalink[] public permalinks: Permalink[]
@@ -142,6 +146,10 @@ class Page {
errors: frontmatterErrors, errors: frontmatterErrors,
}: ReadFileContentsResult = await readFileContents(fullPath) }: ReadFileContentsResult = await readFileContents(fullPath)
// Get file modification time
const stats = await fs.stat(fullPath)
const mtime = stats.mtimeMs
// The `|| ''` is for pages that are purely frontmatter. // The `|| ''` is for pages that are purely frontmatter.
// So the `content` property will be `undefined`. // So the `content` property will be `undefined`.
let markdown = content || '' let markdown = content || ''
@@ -178,6 +186,7 @@ class Page {
fullPath, fullPath,
...(data || {}), ...(data || {}),
markdown, markdown,
mtime,
frontmatterErrors, frontmatterErrors,
} as PageReadResult } as PageReadResult
} catch (err: any) { } catch (err: any) {

View File

@@ -70,10 +70,10 @@ export default async function contextualize(
req.context.redirects = redirects req.context.redirects = redirects
req.context.site = { req.context.site = {
data: { data: {
ui: getUIDataMerged(req.language), ui: getUIDataMerged(req.language!),
}, },
} }
req.context.getDottedData = (dottedPath) => getDataByLanguage(dottedPath, req.language) req.context.getDottedData = (dottedPath) => getDataByLanguage(dottedPath, req.language!)
req.context.siteTree = siteTree req.context.siteTree = siteTree
req.context.pages = pageMap req.context.pages = pageMap
req.context.nonEnterpriseDefaultVersion = nonEnterpriseDefaultVersion req.context.nonEnterpriseDefaultVersion = nonEnterpriseDefaultVersion

View File

@@ -39,7 +39,7 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne
// injected there it needs to have its own possible Liquid rendered out. // injected there it needs to have its own possible Liquid rendered out.
const glossariesRaw: Glossary[] = getDataByLanguage( const glossariesRaw: Glossary[] = getDataByLanguage(
'glossaries.external', 'glossaries.external',
req.context.currentLanguage, req.context.currentLanguage!,
) )
const glossaries = ( const glossaries = (
await Promise.all( await Promise.all(

View File

@@ -9,6 +9,14 @@ import { allVersions } from '@/versions/lib/all-versions'
import enterpriseServerReleases, { latest } from '@/versions/lib/enterprise-server-releases' import enterpriseServerReleases, { latest } from '@/versions/lib/enterprise-server-releases'
import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version'
interface TestContext {
currentVersion: string
currentLanguage: string
currentPath?: string
enterpriseServerVersions?: string[]
[key: string]: any
}
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const enterpriseServerVersions = Object.keys(allVersions).filter((v) => const enterpriseServerVersions = Object.keys(allVersions).filter((v) =>
v.startsWith('enterprise-server@'), v.startsWith('enterprise-server@'),
@@ -27,12 +35,14 @@ const opts = {
describe('Page class', () => { describe('Page class', () => {
test('preserves file path info', async () => { test('preserves file path info', async () => {
const page = await Page.init(opts) const page = await Page.init(opts)
expect(page.relativePath).toBe(opts.relativePath) expect(page!.relativePath).toBe(opts.relativePath)
expect(page.fullPath.includes(page.relativePath)).toBe(true) expect(page!.fullPath.includes(page!.relativePath)).toBe(true)
}) })
describe('showMiniToc page property', () => { describe('showMiniToc page property', () => {
let article, articleWithFM, tocPage let article: Page | undefined
let articleWithFM: Page | undefined
let tocPage: Page | undefined
beforeAll(async () => { beforeAll(async () => {
article = await Page.init({ article = await Page.init({
@@ -43,10 +53,10 @@ describe('Page class', () => {
articleWithFM = await Page.init({ articleWithFM = await Page.init({
showMiniToc: false, showMiniToc: false,
relativePath: article.relativePath, relativePath: article!.relativePath,
basePath: article.basePath, basePath: article!.basePath,
languageCode: article.languageCode, languageCode: article!.languageCode,
}) } as any)
tocPage = await Page.init({ tocPage = await Page.init({
relativePath: 'sample-toc-index.md', relativePath: 'sample-toc-index.md',
@@ -56,16 +66,16 @@ describe('Page class', () => {
}) })
test('is true by default on articles', () => { test('is true by default on articles', () => {
expect(article.showMiniToc).toBe(true) expect(article!.showMiniToc).toBe(true)
}) })
test('is false on articles when set in frontmatter', () => { test('is false on articles when set in frontmatter', () => {
expect(articleWithFM.showMiniToc).toBe(false) expect(articleWithFM!.showMiniToc).toBe(false)
}) })
// products, categories, and subcategories have index.md pages // products, categories, and subcategories have index.md pages
test('is undefined by default on index.md pages', () => { test('is undefined by default on index.md pages', () => {
expect(tocPage.showMiniToc).toBeUndefined() expect(tocPage!.showMiniToc).toBeUndefined()
}) })
}) })
@@ -80,40 +90,40 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
// set version to the latest enterprise version // set version to the latest enterprise version
const context = { const context: TestContext = {
currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`, currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`,
currentLanguage: 'en', currentLanguage: 'en',
enterpriseServerVersions, enterpriseServerVersions,
} }
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page.relativePath}` context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}`
let rendered = await page.render(context) let rendered = await page!.render(context)
let $ = cheerio.load(rendered) let $ = cheerio.load(rendered)
expect($.text()).toBe( expect(($ as any).text()).toBe(
'This text should render on any actively supported version of Enterprise Server', 'This text should render on any actively supported version of Enterprise Server',
) )
expect($.text()).not.toBe('This text should only render on non-Enterprise') expect(($ as any).text()).not.toBe('This text should only render on non-Enterprise')
// change version to the oldest enterprise version, re-render, and test again; // change version to the oldest enterprise version, re-render, and test again;
// the results should be the same // the results should be the same
context.currentVersion = `enterprise-server@${enterpriseServerReleases.oldestSupported}` context.currentVersion = `enterprise-server@${enterpriseServerReleases.oldestSupported}`
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page.relativePath}` context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}`
rendered = await page.render(context) rendered = await page!.render(context)
$ = cheerio.load(rendered) $ = cheerio.load(rendered)
expect($.text()).toBe( expect(($ as any).text()).toBe(
'This text should render on any actively supported version of Enterprise Server', 'This text should render on any actively supported version of Enterprise Server',
) )
expect($.text()).not.toBe('This text should only render on non-Enterprise') expect(($ as any).text()).not.toBe('This text should only render on non-Enterprise')
// change version to non-enterprise, re-render, and test again; // change version to non-enterprise, re-render, and test again;
// the results should be the opposite // the results should be the opposite
context.currentVersion = nonEnterpriseDefaultVersion context.currentVersion = nonEnterpriseDefaultVersion
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page.relativePath}` context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}`
rendered = await page.render(context) rendered = await page!.render(context)
$ = cheerio.load(rendered) $ = cheerio.load(rendered)
expect($.text()).not.toBe( expect(($ as any).text()).not.toBe(
'This text should render on any actively supported version of Enterprise Server', 'This text should render on any actively supported version of Enterprise Server',
) )
expect($.text()).toBe('This text should only render on non-Enterprise') expect(($ as any).text()).toBe('This text should only render on non-Enterprise')
}) })
test('support next to-be-released Enterprise Server version in frontmatter', async () => { test('support next to-be-released Enterprise Server version in frontmatter', async () => {
@@ -124,19 +134,19 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
// set version to 3.0 // set version to 3.0
const context = { const context: TestContext = {
currentVersion: 'enterprise-server@3.0', currentVersion: 'enterprise-server@3.0',
currentLanguage: 'en', currentLanguage: 'en',
} }
await expect(() => { await expect(() => {
return page.render(context) return page!.render(context)
}).not.toThrow() }).not.toThrow()
}) })
}) })
test('preserves `languageCode`', async () => { test('preserves `languageCode`', async () => {
const page = await Page.init(opts) const page = await Page.init(opts)
expect(page.languageCode).toBe('en') expect(page!.languageCode).toBe('en')
}) })
test('parentProductId getter', async () => { test('parentProductId getter', async () => {
@@ -145,32 +155,32 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.parentProductId).toBe('github') expect(page!.parentProductId).toBe('github')
page = await Page.init({ page = await Page.init({
relativePath: 'actions/some-category/some-article.md', relativePath: 'actions/some-category/some-article.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.parentProductId).toBe('actions') expect(page!.parentProductId).toBe('actions')
page = await Page.init({ page = await Page.init({
relativePath: 'admin/some-category/some-article.md', relativePath: 'admin/some-category/some-article.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.parentProductId).toBe('admin') expect(page!.parentProductId).toBe('admin')
}) })
describe('permalinks', () => { describe('permalinks', () => {
test('is an array', async () => { test('is an array', async () => {
const page = await Page.init(opts) const page = await Page.init(opts)
expect(Array.isArray(page.permalinks)).toBe(true) expect(Array.isArray(page!.permalinks)).toBe(true)
}) })
test('has a key for every supported enterprise version (and no deprecated versions)', async () => { test('has a key for every supported enterprise version (and no deprecated versions)', async () => {
const page = await Page.init(opts) const page = await Page.init(opts)
const pageVersions = page.permalinks.map((permalink) => permalink.pageVersion) const pageVersions = page!.permalinks.map((permalink: any) => permalink.pageVersion)
expect( expect(
enterpriseServerReleases.supported.every((version) => enterpriseServerReleases.supported.every((version) =>
pageVersions.includes(`enterprise-server@${version}`), pageVersions.includes(`enterprise-server@${version}`),
@@ -188,15 +198,16 @@ describe('Page class', () => {
const expectedPath = const expectedPath =
'pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches' 'pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches'
expect( expect(
page.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion) page!.permalinks.find(
.href, (permalink: any) => permalink.pageVersion === nonEnterpriseDefaultVersion,
)!.href,
).toBe(`/en/${expectedPath}`) ).toBe(`/en/${expectedPath}`)
expect( expect(
page.permalinks.find( page!.permalinks.find(
(permalink) => (permalink: any) =>
permalink.pageVersion === permalink.pageVersion ===
`enterprise-server@${enterpriseServerReleases.oldestSupported}`, `enterprise-server@${enterpriseServerReleases.oldestSupported}`,
).href, )!.href,
).toBe(`/en/enterprise-server@${enterpriseServerReleases.oldestSupported}/${expectedPath}`) ).toBe(`/en/enterprise-server@${enterpriseServerReleases.oldestSupported}/${expectedPath}`)
}) })
@@ -207,15 +218,15 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
expect( expect(
page.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion) page!.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion)
.href, ?.href,
).toBe('/en') ).toBe('/en')
expect( expect(
page.permalinks.find( page!.permalinks.find(
(permalink) => (permalink) =>
permalink.pageVersion === permalink.pageVersion ===
`enterprise-server@${enterpriseServerReleases.oldestSupported}`, `enterprise-server@${enterpriseServerReleases.oldestSupported}`,
).href, )?.href,
).toBe(`/en/enterprise-server@${enterpriseServerReleases.oldestSupported}`) ).toBe(`/en/enterprise-server@${enterpriseServerReleases.oldestSupported}`)
}) })
@@ -226,15 +237,14 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
expect( expect(
page.permalinks.find( page!.permalinks.find(
(permalink) => (permalink: any) => permalink.pageVersion === `enterprise-server@${latest}`,
permalink.pageVersion === `enterprise-server@${enterpriseServerReleases.latest}`, )!.href,
).href,
).toBe( ).toBe(
`/en/enterprise-server@${enterpriseServerReleases.latest}/products/admin/some-category/some-article`, `/en/enterprise-server@${enterpriseServerReleases.latest}/products/admin/some-category/some-article`,
) )
const pageVersions = page.permalinks.map((permalink) => permalink.pageVersion) const pageVersions = page!.permalinks.map((permalink: any) => permalink.pageVersion)
expect(pageVersions.length).toBeGreaterThan(1) expect(page!.permalinks.length).toBeGreaterThan(0)
expect(pageVersions.includes(nonEnterpriseDefaultVersion)).toBe(false) expect(pageVersions.includes(nonEnterpriseDefaultVersion)).toBe(false)
}) })
@@ -245,15 +255,16 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
expect( expect(
page.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion) page!.permalinks.find(
.href, (permalink: any) => permalink.pageVersion === nonEnterpriseDefaultVersion,
)!.href,
).toBe('/en/products/actions/some-category/some-article') ).toBe('/en/products/actions/some-category/some-article')
expect(page.permalinks.length).toBe(1) expect(page!.permalinks.length).toBe(1)
}) })
}) })
describe('videos', () => { describe('videos', () => {
let page let page: Page | undefined
beforeEach(async () => { beforeEach(async () => {
page = await Page.init({ page = await Page.init({
@@ -264,7 +275,7 @@ describe('Page class', () => {
}) })
test('includes videos specified in the featuredLinks frontmatter', async () => { test('includes videos specified in the featuredLinks frontmatter', async () => {
expect(page.featuredLinks.videos).toStrictEqual([ expect((page as any)!.featuredLinks.videos).toStrictEqual([
{ {
title: 'codespaces', title: 'codespaces',
href: 'https://www.youtube-nocookie.com/embed/_W9B7qc9lVc', href: 'https://www.youtube-nocookie.com/embed/_W9B7qc9lVc',
@@ -279,7 +290,7 @@ describe('Page class', () => {
}, },
]) ])
expect(page.featuredLinks.videosHeading).toBe('Custom Videos heading') expect((page as any)!.featuredLinks.videosHeading).toBe('Custom Videos heading')
}) })
}) })
@@ -291,7 +302,7 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
expect(page.introLinks).toStrictEqual({ expect(page!.introLinks).toStrictEqual({
overview: 'https://github.com', overview: 'https://github.com',
'custom link!': 'https://github.com/features', 'custom link!': 'https://github.com/features',
}) })
@@ -302,7 +313,7 @@ describe('Page class', () => {
test('throws an error on bad input', () => { test('throws an error on bad input', () => {
const markdown = null const markdown = null
expect(() => { expect(() => {
Page.parseFrontmatter('some/file.md', markdown) ;(Page as any).parseFrontmatter('some/file.md', markdown)
}).toThrow() }).toThrow()
}) })
}) })
@@ -314,10 +325,10 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.versions.fpt).toBe('*') expect((page!.versions as any).fpt).toBe('*')
expect(page.versions.ghes).toBe('>3.0') expect((page!.versions as any).ghes).toBe('>3.0')
expect(page.applicableVersions.includes('free-pro-team@latest')).toBe(true) expect(page!.applicableVersions.includes('free-pro-team@latest')).toBe(true)
expect(page.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true) expect(page!.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
}) })
test('index page', async () => { test('index page', async () => {
@@ -326,7 +337,7 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../content'), basePath: path.join(__dirname, '../../../content'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.versions).toEqual({ fpt: '*', ghec: '*', ghes: '*' }) expect(page!.versions).toEqual({ fpt: '*', ghec: '*', ghes: '*' })
}) })
test('enterprise admin index page', async () => { test('enterprise admin index page', async () => {
@@ -336,8 +347,8 @@ describe('Page class', () => {
languageCode: 'en', languageCode: 'en',
}) })
expect(nonEnterpriseDefaultPlan in page.versions).toBe(false) expect(nonEnterpriseDefaultPlan in page!.versions).toBe(false)
expect(page.versions.ghes).toBe('*') expect(page!.versions.ghes).toBe('*')
}) })
test('feature versions frontmatter', async () => { test('feature versions frontmatter', async () => {
@@ -361,9 +372,8 @@ describe('Page class', () => {
}) })
// Test the raw page data. // Test the raw page data.
expect(page.versions.fpt).toBe('*') expect(page!.versions.fpt).toBe('*')
expect(page.versions.ghes).toBe('>2.21') expect(page!.versions.ghes).toBe('>2.21')
expect(page.versions.ghae).toBeUndefined()
// Test the resolved versioning, where GHES releases specified in frontmatter and in // Test the resolved versioning, where GHES releases specified in frontmatter and in
// feature versions are combined (i.e., one doesn't overwrite the other). // feature versions are combined (i.e., one doesn't overwrite the other).
@@ -372,10 +382,10 @@ describe('Page class', () => {
// so as soon as 2.21 is deprecated, a test for that _not_ to exist will not be meaningful. // so as soon as 2.21 is deprecated, a test for that _not_ to exist will not be meaningful.
// But by testing that the _latest_ GHES version is returned, we can ensure that the // But by testing that the _latest_ GHES version is returned, we can ensure that the
// the frontmatter GHES `*` is not being overwritten by the placeholder's GHES `<3.0`. // the frontmatter GHES `*` is not being overwritten by the placeholder's GHES `<3.0`.
expect(page.applicableVersions.includes('free-pro-team@latest')).toBe(true) expect(page!.applicableVersions.includes('free-pro-team@latest')).toBe(true)
expect(page.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true) expect(page!.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
expect(page.applicableVersions.includes('feature')).toBe(false) expect(page!.applicableVersions.includes('feature')).toBe(false)
expect(page.applicableVersions.includes('placeholder')).toBe(false) expect(page!.applicableVersions.includes('placeholder')).toBe(false)
}) })
}) })
@@ -386,8 +396,8 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.defaultPlatform).toBeDefined() expect((page as any)!.defaultPlatform).toBeDefined()
expect(page.defaultPlatform).toBe('linux') expect((page as any)!.defaultPlatform).toBe('linux')
}) })
}) })
@@ -398,8 +408,8 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en', languageCode: 'en',
}) })
expect(page.defaultTool).toBeDefined() expect((page as any)!.defaultTool).toBeDefined()
expect(page.defaultTool).toBe('cli') expect((page as any)!.defaultTool).toBe('cli')
}) })
}) })
}) })
@@ -461,7 +471,7 @@ describe('catches errors thrown in Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en', languageCode: 'en',
}) })
const context = { const context: any = {
page: { version: `enterprise-server@3.2` }, page: { version: `enterprise-server@3.2` },
currentVersion: `enterprise-server@3.2`, currentVersion: `enterprise-server@3.2`,
currentVersionObj: {}, currentVersionObj: {},
@@ -471,18 +481,18 @@ describe('catches errors thrown in Page class', () => {
fpt: false, // what the shortVersions contextualizer does fpt: false, // what the shortVersions contextualizer does
} }
await page.render(context) await page!.render(context)
expect(page.product).toBe('') expect(page!.product).toBe('')
expect(page.permissions).toBe('') expect(page!.permissions).toBe('')
// Change to FPT // Change to FPT
context.page.version = nonEnterpriseDefaultVersion context.page.version = nonEnterpriseDefaultVersion
context.version = nonEnterpriseDefaultVersion context.version = nonEnterpriseDefaultVersion
context.currentPath = '/en/optional/attributes' context.currentPath = '/en/optional/attributes'
context.fpt = true context.fpt = true
await page.render(context) await page!.render(context)
expect(page.product).toContain('FPT rulez!') expect(page!.product).toContain('FPT rulez!')
expect(page.permissions).toContain('FPT only!') expect(page!.permissions).toContain('FPT only!')
}) })
}) })
}) })

View File

@@ -1,3 +1,4 @@
// csp-parse doesn't have TypeScript types
import CspParse from 'csp-parse' import CspParse from 'csp-parse'
import { beforeAll, describe, expect, test, vi } from 'vitest' import { beforeAll, describe, expect, test, vi } from 'vitest'
@@ -10,6 +11,11 @@ import {
makeLanguageSurrogateKey, makeLanguageSurrogateKey,
} from '@/frame/middleware/set-fastly-surrogate-key' } from '@/frame/middleware/set-fastly-surrogate-key'
interface Category {
name: string
published_articles: string[]
}
describe('server', () => { describe('server', () => {
vi.setConfig({ testTimeout: 60 * 1000 }) vi.setConfig({ testTimeout: 60 * 1000 })
@@ -81,11 +87,14 @@ describe('server', () => {
// it will render a very basic plain text 404 response. // it will render a very basic plain text 404 response.
const $ = await getDOM('/en/not-a-real-page', { allow404: true }) const $ = await getDOM('/en/not-a-real-page', { allow404: true })
expect($('h1').first().text()).toBe('Ooops!') expect($('h1').first().text()).toBe('Ooops!')
expect($.text().includes("It looks like this page doesn't exist.")).toBe(true) // Using type assertion because cheerio v1 types don't include text() on root
expect(($ as any).text().includes("It looks like this page doesn't exist.")).toBe(true)
expect( expect(
$.text().includes( ($ as any)
'We track these errors automatically, but if the problem persists please feel free to contact us.', .text()
), .includes(
'We track these errors automatically, but if the problem persists please feel free to contact us.',
),
).toBe(true) ).toBe(true)
expect($.res.statusCode).toBe(404) expect($.res.statusCode).toBe(404)
}) })
@@ -107,11 +116,14 @@ describe('server', () => {
test('renders a 500 page when errors are thrown', async () => { test('renders a 500 page when errors are thrown', async () => {
const $ = await getDOM('/_500', { allow500s: true }) const $ = await getDOM('/_500', { allow500s: true })
expect($('h1').first().text()).toBe('Ooops!') expect($('h1').first().text()).toBe('Ooops!')
expect($.text().includes('It looks like something went wrong.')).toBe(true) // Using type assertion because cheerio v1 types don't include text() on root
expect(($ as any).text().includes('It looks like something went wrong.')).toBe(true)
expect( expect(
$.text().includes( ($ as any)
'We track these errors automatically, but if the problem persists please feel free to contact us.', .text()
), .includes(
'We track these errors automatically, but if the problem persists please feel free to contact us.',
),
).toBe(true) ).toBe(true)
expect($.res.statusCode).toBe(500) expect($.res.statusCode).toBe(500)
}) })
@@ -154,7 +166,7 @@ describe('server', () => {
const categories = JSON.parse(res.body) const categories = JSON.parse(res.body)
expect(Array.isArray(categories)).toBe(true) expect(Array.isArray(categories)).toBe(true)
expect(categories.length).toBeGreaterThan(1) expect(categories.length).toBeGreaterThan(1)
categories.forEach((category) => { categories.forEach((category: Category) => {
expect('name' in category).toBe(true) expect('name' in category).toBe(true)
expect('published_articles' in category).toBe(true) expect('published_articles' in category).toBe(true)
}) })

View File

@@ -19,15 +19,13 @@ import loadRedirects from '@/redirects/lib/precompile'
import { loadPageMap, loadPages } from '@/frame/lib/page-data' import { loadPageMap, loadPages } from '@/frame/lib/page-data'
import { languageKeys } from '@/languages/lib/languages' import { languageKeys } from '@/languages/lib/languages'
import { RewriteAssetPathsPlugin } from '@/ghes-releases/scripts/deprecate/rewrite-asset-paths' import { RewriteAssetPathsPlugin } from '@/ghes-releases/scripts/deprecate/rewrite-asset-paths'
import type { Page } from '@/types' import Page from '@/frame/lib/page'
const port = '4001' const port = '4001'
const host = `http://localhost:${port}` const host = `http://localhost:${port}`
const version = EnterpriseServerReleases.oldestSupported const version = EnterpriseServerReleases.oldestSupported
const GH_PAGES_URL = `https://github.github.com/docs-ghes-${version}` const GH_PAGES_URL = `https://github.github.com/docs-ghes-${version}`
// Once page-data.js is converted to TS,
// we can import the more comprehesive type
type PageList = Page[] type PageList = Page[]
type MapObj = { [key: string]: string } type MapObj = { [key: string]: string }

View File

@@ -34,7 +34,7 @@ describe('server rendering certain GraphQL pages', () => {
) )
.filter(Boolean) .filter(Boolean)
const nonFPTPermalinksHrefs = nonFPTPermalinks.map((permalink) => { const nonFPTPermalinksHrefs = nonFPTPermalinks.map((permalink) => {
return permalink.href return permalink!.href
}) })
test.each(nonFPTPermalinksHrefs)( test.each(nonFPTPermalinksHrefs)(

View File

@@ -43,7 +43,7 @@ export default async function processLearningTracks(
// fall back to English if they don't exist on disk in the translation. // fall back to English if they don't exist on disk in the translation.
const track = getDataByLanguage( const track = getDataByLanguage(
`learning-tracks.${context.currentProduct}.${renderedTrackName}`, `learning-tracks.${context.currentProduct}.${renderedTrackName}`,
context.currentLanguage, context.currentLanguage!,
) )
if (!track) { if (!track) {
throw new Error(`No learning track called '${renderedTrackName}'.`) throw new Error(`No learning track called '${renderedTrackName}'.`)

View File

@@ -29,7 +29,10 @@ export default async function learningTrack(
let trackProduct = req.context.currentProduct as string let trackProduct = req.context.currentProduct as string
// TODO: Once getDeepDataByLanguage is ported to TS // TODO: Once getDeepDataByLanguage is ported to TS
// a more appropriate API would be to use `getDeepDataByLanguage<LearningTracks)(...)` // a more appropriate API would be to use `getDeepDataByLanguage<LearningTracks)(...)`
const allLearningTracks = getDeepDataByLanguage('learning-tracks', req.language) as LearningTracks const allLearningTracks = getDeepDataByLanguage(
'learning-tracks',
req.language!,
) as LearningTracks
if (req.language !== 'en') { if (req.language !== 'en') {
// Don't trust the `.guides` from the translation. It too often has // Don't trust the `.guides` from the translation. It too often has

6
src/tests/declarations.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module 'csp-parse' {
export default class CspParse {
constructor(cspHeader: string)
get(directive: string): string | string[]
}
}

View File

@@ -225,7 +225,7 @@ export type FeaturedLinkExpanded = {
intro?: string intro?: string
} }
type FeaturedLinksExpanded = { export type FeaturedLinksExpanded = {
[key: string]: FeaturedLinkExpanded[] [key: string]: FeaturedLinkExpanded[]
} }