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 Page = {
autogenerated?: boolean
autogenerated?: string
fullPath: string
permalinks: Array<{ href: string }>
versions: {

View File

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

View File

@@ -8,6 +8,14 @@ import { merge, get } from 'lodash-es'
import languages from '@/languages/lib/languages'
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,
// 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'))
@@ -23,29 +31,33 @@ const ALWAYS_ENGLISH_MD_FILES = new Set([
])
// Returns all the things inside a directory
export const getDeepDataByLanguage = memoize((dottedPath, langCode, dir = null) => {
if (!(langCode in languages))
throw new Error(`langCode '${langCode}' not a recognized language code`)
export const getDeepDataByLanguage = memoize(
(dottedPath: string, langCode: string, dir: string | null = null): any => {
if (!(langCode in languages)) {
throw new Error(`langCode '${langCode}' not a recognized language code`)
}
// The `dir` argument is only used for testing purposes.
// For example, our unit tests that depend on using a fixtures
// root.
// If we don't allow those tests to override the `dir` argument,
// it'll be stuck from the first time `languages.js` was imported.
if (dir === null) {
dir = languages[langCode].dir
}
return getDeepDataByDir(dottedPath, dir)
})
// The `dir` argument is only used for testing purposes.
// For example, our unit tests that depend on using a fixtures
// root.
// If we don't allow those tests to override the `dir` argument,
// it'll be stuck from the first time `languages.js` was imported.
let actualDir = dir
if (actualDir === null) {
actualDir = languages[langCode].dir
}
return getDeepDataByDir(dottedPath, actualDir)
},
)
// Doesn't need to be memoized because it's used by getDataKeysByLanguage
// which is already memoized.
function getDeepDataByDir(dottedPath, dir) {
function getDeepDataByDir(dottedPath: string, dir: string): any {
const fullPath = ['data']
const split = dottedPath.split(/\./g)
fullPath.push(...split)
const things = {}
const things: any = {}
const relPath = fullPath.join(path.sep)
for (const dirent of getDirents(dir, relPath)) {
if (dirent.name === 'README.md') continue
@@ -63,12 +75,12 @@ function getDeepDataByDir(dottedPath, dir) {
return things
}
function getDirents(root, relPath) {
function getDirents(root: string, relPath: string): fs.Dirent[] {
const filePath = root ? path.join(root, relPath) : relPath
return fs.readdirSync(filePath, { withFileTypes: true })
}
export const getUIDataMerged = memoize((langCode) => {
export const getUIDataMerged = memoize((langCode: string): any => {
const uiEnglish = getUIData('en')
if (langCode === 'en') return uiEnglish
// 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"}
// =>
// combind = {food: "Mat", drink: "Drink"}
const combined = {}
const combined: any = {}
merge(combined, uiEnglish)
merge(combined, getUIData(langCode))
return combined
@@ -85,13 +97,13 @@ export const getUIDataMerged = memoize((langCode) => {
// Doesn't need to be memoized because it's used by another function
// that is memoized.
const getUIData = (langCode) => {
const getUIData = (langCode: string): any => {
const fullPath = ['data', 'ui.yml']
const { dir } = languages[langCode]
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))
throw new Error(`langCode '${langCode}' not a recognized language code`)
const { dir } = languages[langCode]
@@ -111,7 +123,7 @@ export const getDataByLanguage = memoize((dottedPath, langCode) => {
}
return value
} 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!
// 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
@@ -128,12 +140,17 @@ export const getDataByLanguage = memoize((dottedPath, langCode) => {
throw error
}
if (error.code === 'ENOENT') return undefined
if ((error as FileSystemError).code === 'ENOENT') return undefined
throw error
}
})
function getDataByDir(dottedPath, dir, englishRoot, langCode) {
function getDataByDir(
dottedPath: string,
dir: string,
englishRoot?: string,
langCode?: string,
): any {
const fullPath = ['data']
// 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
//
if (split[0] === 'early-access') {
fullPath.push(split.shift())
fullPath.push(split.shift()!)
}
const first = split[0]
if (first === 'variables') {
const key = split.pop()
const basename = split.pop()
const key = split.pop()!
const basename = split.pop()!
fullPath.push(...split)
fullPath.push(`${basename}.yml`)
const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot)
if (allData) {
if (allData && key) {
const value = allData[key]
if (value) {
return matter(value).content
@@ -177,11 +194,11 @@ function getDataByDir(dottedPath, dir, englishRoot, langCode) {
} else {
console.warn(`Unable to find variables Yaml file ${fullPath.join(path.sep)}`)
}
return
return undefined
}
if (first === 'reusables') {
const nakedname = split.pop()
const nakedname = split.pop()!
fullPath.push(...split)
fullPath.push(`${nakedname}.md`)
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
// sometimes uses to correct some Liquid tags. At least other
// good corrections might happen.
if (error.code !== 'ENOENT') {
if ((error as FileSystemError).code !== 'ENOENT') {
throw error
}
}
@@ -226,25 +243,25 @@ function getDataByDir(dottedPath, dir, englishRoot, langCode) {
}
if (first === 'product-examples' || first === 'glossaries' || first === 'release-notes') {
const basename = split.pop()
const basename = split.pop()!
fullPath.push(...split)
fullPath.push(`${basename}.yml`)
return getYamlContent(dir, fullPath.join(path.sep), englishRoot)
}
if (first === 'learning-tracks') {
const key = split.pop()
const basename = split.pop()
const key = split.pop()!
const basename = split.pop()!
fullPath.push(...split)
fullPath.push(`${basename}.yml`)
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.`)
}
function getSmartSplit(dottedPath) {
function getSmartSplit(dottedPath: string): string[] {
const split = dottedPath.split('.')
const bits = []
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
// -> cache HIT (Yay!)
//
const getYamlContent = memoize((root, relPath, englishRoot) => {
// Certain Yaml files we know we always want the English one
// no matter what the specified language is.
// For example, we never want `data/variables/product.yml` translated
// so we know to immediately fall back to the English one.
if (ALWAYS_ENGLISH_YAML_FILES.has(relPath)) {
// This forces it to read from English. Later, when it goes
// into `getFileContent(...)` it will note that `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 getYamlContent = memoize(
(root: string | undefined, relPath: string, englishRoot?: string): any => {
// Certain Yaml files we know we always want the English one
// no matter what the specified language is.
// For example, we never want `data/variables/product.yml` translated
// so we know to immediately fall back to the English one.
if (ALWAYS_ENGLISH_YAML_FILES.has(relPath)) {
// This forces it to read from English. Later, when it goes
// into `getFileContent(...)` it will note that `root !== englishRoot`
// so it won't try to fall back.
root = englishRoot
}
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.
const getMarkdownContent = memoize((root, relPath, englishRoot) => {
// Certain reusables we never want to be pulled from the translations.
// For example, certain reusables don't contain any English prose. Just
// facts like numbers or hardcoded key words.
// If this is the case, forcibly always draw from the English files.
if (ALWAYS_ENGLISH_MD_FILES.has(relPath)) {
root = englishRoot
}
const getMarkdownContent = memoize(
(root: string | undefined, relPath: string, englishRoot?: string): string => {
// Certain reusables we never want to be pulled from the translations.
// For example, certain reusables don't contain any English prose. Just
// facts like numbers or hardcoded key words.
// If this is the case, forcibly always draw from the English files.
if (ALWAYS_ENGLISH_MD_FILES.has(relPath)) {
root = englishRoot
}
const fileContent = getFileContent(root, relPath, englishRoot)
return matter(fileContent).content.trimEnd()
})
const fileContent = getFileContent(root, relPath, englishRoot)
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
if (DEBUG_JIT_DATA_READS) console.log('READ', filePath)
try {
@@ -321,10 +346,10 @@ const getFileContent = (root, relPath, englishRoot) => {
} catch (err) {
// It might fail because that particular data entry doesn't yet
// 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
// read was for a translation.
if (root !== englishRoot) {
if (englishRoot && root !== englishRoot) {
// We can try again but this time using the English files
return getFileContent(englishRoot, relPath, englishRoot)
}
@@ -333,9 +358,9 @@ const getFileContent = (root, relPath, englishRoot) => {
}
}
function memoize(func) {
const cache = new Map()
return (...args) => {
function memoize<T extends (...args: any[]) => any>(func: T): T {
const cache = new Map<string, any>()
return ((...args: any[]) => {
if (process.env.NODE_ENV === 'development') {
// It is very possible that certain files, when caching is disabled,
// 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 (typeof value === 'object') return { ...value }
return value
}
}) as T
}

View File

@@ -12,7 +12,7 @@ import {
import { DataDirectory } from '@/tests/helpers/data-directory'
describe('get-data', () => {
let dd
let dd: DataDirectory
const enDirBefore = languages.en.dir
// Only `en` is available in tests, so pretend we also have Japanese
languages.ja = Object.assign({}, languages.en, {})
@@ -219,7 +219,7 @@ front: >'matter
`.trim()
describe('get-data on corrupt translations', () => {
let dd
let dd: DataDirectory
const enDirBefore = languages.en.dir
// Only `en` is available in vitest tests, so pretend we also have Japanese
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 { 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 = [
'default',
'graphql-explorer',
@@ -38,7 +64,7 @@ export const contentTypesEnum = [
'other', // Everything else.
]
export const schema = {
export const schema: Schema = {
type: 'object',
required: ['title', 'versions'],
additionalProperties: false,
@@ -383,8 +409,8 @@ export const schema = {
}
// returns a list of deprecated properties
export const deprecatedProperties = Object.keys(schema.properties).filter((prop) => {
return schema.properties[prop].deprecated
export const deprecatedProperties = Object.keys(schema.properties).filter((prop: string) => {
return (schema.properties as Record<string, SchemaProperty>)[prop].deprecated
})
const featureVersionsProp = {
@@ -407,17 +433,17 @@ const semverRange = {
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
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.shortName] = semverRange
return acc
}, featureVersionsProp),
}
export function frontmatter(markdown, opts = {}) {
export function frontmatter(markdown: string, opts: any = {}) {
const defaults = {
schema,
}

View File

@@ -1,5 +1,8 @@
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 { allVersions } from '@/versions/lib/all-versions'
import createTree from './create-tree'
@@ -10,6 +13,10 @@ import Permalink from './permalink'
import frontmatterSchema from './frontmatter'
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,
// every time a translation file fails to initialize we fall back to English
// 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
// which is surprising since only a single page has prose in it.
const translatableFrontmatterKeys = Object.entries(frontmatterSchema.schema.properties)
.filter(([, value]) => value.translatable)
.filter(([, value]: [string, any]) => value.translatable)
.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
* 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)) {
throw new Error("'languagesOnly' has to be an array")
}
const unversionedTree = {}
unversionedTree.en = await createTree(path.join(languages.en.dir, 'content'))
setCategoryApplicableVersions(unversionedTree.en)
const unversionedTree: UnversionLanguageTree = {} as UnversionLanguageTree
const enTree = await createTree(path.join(languages.en.dir, 'content'))
if (enTree) {
unversionedTree.en = enTree
setCategoryApplicableVersions(unversionedTree.en)
}
const languagesValues = Object.entries(languages)
.filter(([language]) => {
@@ -70,14 +82,14 @@ export async function loadUnversionedTree(languagesOnly = []) {
return unversionedTree
}
function setCategoryApplicableVersions(tree) {
function setCategoryApplicableVersions(tree: UnversionedTree): void {
// Now that the tree has been fully computed, we can for any node that
// is a category page, re-set its `.applicableVersions` and `.permalinks`
// based on the union set of all its immediate children's
// `.applicableVersions`.
for (const childPage of tree.childPages) {
if (childPage.page.relativePath.endsWith('index.md')) {
const combinedApplicableVersions = []
const combinedApplicableVersions: string[] = []
let moreThanOneChild = false
for (const childChildPage of childPage.childPages || []) {
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))
}
async function translateTree(dir, langObj, enTree) {
const item = {}
async function translateTree(
dir: string,
langObj: Language,
enTree: UnversionedTree,
): Promise<UnversionedTree> {
const item: Partial<UnversionedTree> = {}
const enPage = enTree.page
const { ...enData } = enPage
@@ -170,13 +186,14 @@ async function translateTree(dir, langObj, enTree) {
if (THROW_TRANSLATION_ERRORS) {
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) {
// If it didn't work because it didn't exist, don't fret,
// 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
content = enPage.markdown
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,
})
translatedData.title = correctTranslatedContentStrings(translatedData.title, enPage.title, {
translatedData.title = correctTranslatedContentStrings(translatedData.title || '', enPage.title, {
relativePath,
code: langObj.code,
})
if (translatedData.shortTitle) {
translatedData.shortTitle = correctTranslatedContentStrings(
translatedData.shortTitle,
enPage.shortTitle,
enPage.shortTitle || '',
{
relativePath,
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(
{},
// By default, shallow-copy everything from the English equivalent.
@@ -239,20 +257,23 @@ async function translateTree(dir, langObj, enTree) {
},
// And the translations translated properties.
translatedData,
),
)
if (item.page.children) {
item.childPages = await Promise.all(
) as any,
) as any
if (
((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
.filter((childTree) => {
.filter((childTree: UnversionedTree) => {
// Translations should not get early access pages at all.
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).
*/
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 siteTree = {}
const siteTree: SiteTree = {}
const langCodes = (languagesOnly.length && languagesOnly) || Object.keys(languages)
// For every language...
@@ -278,7 +302,7 @@ export async function loadSiteTree(unversionedTree, languagesOnly = []) {
if (!(langCode in rawTree)) {
throw new Error(`No tree for language ${langCode}`)
}
const treePerVersion = {}
const treePerVersion: { [version: string]: Tree } = {}
// in every version...
await Promise.all(
versions.map(async (version) => {
@@ -298,10 +322,10 @@ export async function loadSiteTree(unversionedTree, languagesOnly = []) {
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.
const permalink = obj.page.permalinks.find(
(pl) =>
(pl: any) =>
pl.pageVersion === version ||
(pl.pageVersion === 'homepage' && version === nonEnterpriseDefaultVersion),
)
@@ -316,9 +340,9 @@ export async function versionPages(obj, version, langCode) {
const versionedChildPages = await Promise.all(
obj.childPages
// 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.
.map((childPage) => versionPages(Object.assign({}, childPage), version, langCode)),
.map((childPage: any) => versionPages(Object.assign({}, childPage), version, langCode)),
)
obj.childPages = [...versionedChildPages]
@@ -327,12 +351,15 @@ export async function versionPages(obj, version, langCode) {
}
// 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)) {
throw new Error("'languagesOnly' has to be an array")
}
const rawTree = unversionedTree || (await loadUnversionedTree(languagesOnly))
const pageList = []
const pageList: Page[] = []
const langCodes = (languagesOnly.length && languagesOnly) || Object.keys(languages)
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
collection.push(item.page)
collection.push(item.page as any)
if (!item.childPages) return
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
// Create an object from the list of all pages with permalinks as keys for fast lookup.
export function createMapFromArray(pageList) {
const pageMap = pageList.reduce((pageMap, page) => {
for (const permalink of page.permalinks) {
pageMap[permalink.href] = page
}
return pageMap
}, {})
export function createMapFromArray(pageList: Page[]): Record<string, Page> {
const pageMap = pageList.reduce(
(pageMap: Record<string, Page>, page: Page) => {
for (const permalink of page.permalinks) {
pageMap[permalink.href] = page
}
return pageMap
},
{} as Record<string, Page>,
)
return pageMap
}
export async function loadPageMap(pageList, languagesOnly = []) {
const pages = pageList || (await loadPageList(languagesOnly))
export async function loadPageMap(
pageList?: Page[],
languagesOnly: string[] = [],
): Promise<Record<string, Page>> {
const pages = pageList || (await loadPageList(undefined, languagesOnly))
const pageMap = createMapFromArray(pages)
return pageMap
}

View File

@@ -1,5 +1,6 @@
import assert from 'assert'
import path from 'path'
import fs from 'fs/promises'
import cheerio from 'cheerio'
import getApplicableVersions from '@/versions/lib/get-applicable-versions'
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 { 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
// the relativePath) against `productMap` to make sure it's valid.
@@ -97,6 +98,8 @@ class Page {
public rawIntroLinks?: Record<string, string>
public recommended?: string[]
public rawRecommended?: string[]
public autogenerated?: string
public featuredLinks?: FeaturedLinksExpanded
// Derived properties
public languageCode!: string
@@ -104,6 +107,7 @@ class Page {
public basePath!: string
public fullPath!: string
public markdown!: string
public mtime!: number
public documentType: string
public applicableVersions: string[]
public permalinks: Permalink[]
@@ -142,6 +146,10 @@ class Page {
errors: frontmatterErrors,
}: 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.
// So the `content` property will be `undefined`.
let markdown = content || ''
@@ -178,6 +186,7 @@ class Page {
fullPath,
...(data || {}),
markdown,
mtime,
frontmatterErrors,
} as PageReadResult
} catch (err: any) {

View File

@@ -70,10 +70,10 @@ export default async function contextualize(
req.context.redirects = redirects
req.context.site = {
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.pages = pageMap
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.
const glossariesRaw: Glossary[] = getDataByLanguage(
'glossaries.external',
req.context.currentLanguage,
req.context.currentLanguage!,
)
const glossaries = (
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 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 enterpriseServerVersions = Object.keys(allVersions).filter((v) =>
v.startsWith('enterprise-server@'),
@@ -27,12 +35,14 @@ const opts = {
describe('Page class', () => {
test('preserves file path info', async () => {
const page = await Page.init(opts)
expect(page.relativePath).toBe(opts.relativePath)
expect(page.fullPath.includes(page.relativePath)).toBe(true)
expect(page!.relativePath).toBe(opts.relativePath)
expect(page!.fullPath.includes(page!.relativePath)).toBe(true)
})
describe('showMiniToc page property', () => {
let article, articleWithFM, tocPage
let article: Page | undefined
let articleWithFM: Page | undefined
let tocPage: Page | undefined
beforeAll(async () => {
article = await Page.init({
@@ -43,10 +53,10 @@ describe('Page class', () => {
articleWithFM = await Page.init({
showMiniToc: false,
relativePath: article.relativePath,
basePath: article.basePath,
languageCode: article.languageCode,
})
relativePath: article!.relativePath,
basePath: article!.basePath,
languageCode: article!.languageCode,
} as any)
tocPage = await Page.init({
relativePath: 'sample-toc-index.md',
@@ -56,16 +66,16 @@ describe('Page class', () => {
})
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', () => {
expect(articleWithFM.showMiniToc).toBe(false)
expect(articleWithFM!.showMiniToc).toBe(false)
})
// products, categories, and subcategories have 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',
})
// set version to the latest enterprise version
const context = {
const context: TestContext = {
currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`,
currentLanguage: 'en',
enterpriseServerVersions,
}
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page.relativePath}`
let rendered = await page.render(context)
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}`
let rendered = await page!.render(context)
let $ = cheerio.load(rendered)
expect($.text()).toBe(
expect(($ as any).text()).toBe(
'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;
// the results should be the same
context.currentVersion = `enterprise-server@${enterpriseServerReleases.oldestSupported}`
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page.relativePath}`
rendered = await page.render(context)
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}`
rendered = await page!.render(context)
$ = cheerio.load(rendered)
expect($.text()).toBe(
expect(($ as any).text()).toBe(
'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;
// the results should be the opposite
context.currentVersion = nonEnterpriseDefaultVersion
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page.relativePath}`
rendered = await page.render(context)
context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}`
rendered = await page!.render(context)
$ = 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',
)
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 () => {
@@ -124,19 +134,19 @@ describe('Page class', () => {
languageCode: 'en',
})
// set version to 3.0
const context = {
const context: TestContext = {
currentVersion: 'enterprise-server@3.0',
currentLanguage: 'en',
}
await expect(() => {
return page.render(context)
return page!.render(context)
}).not.toThrow()
})
})
test('preserves `languageCode`', async () => {
const page = await Page.init(opts)
expect(page.languageCode).toBe('en')
expect(page!.languageCode).toBe('en')
})
test('parentProductId getter', async () => {
@@ -145,32 +155,32 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en',
})
expect(page.parentProductId).toBe('github')
expect(page!.parentProductId).toBe('github')
page = await Page.init({
relativePath: 'actions/some-category/some-article.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en',
})
expect(page.parentProductId).toBe('actions')
expect(page!.parentProductId).toBe('actions')
page = await Page.init({
relativePath: 'admin/some-category/some-article.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en',
})
expect(page.parentProductId).toBe('admin')
expect(page!.parentProductId).toBe('admin')
})
describe('permalinks', () => {
test('is an array', async () => {
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 () => {
const page = await Page.init(opts)
const pageVersions = page.permalinks.map((permalink) => permalink.pageVersion)
const pageVersions = page!.permalinks.map((permalink: any) => permalink.pageVersion)
expect(
enterpriseServerReleases.supported.every((version) =>
pageVersions.includes(`enterprise-server@${version}`),
@@ -188,15 +198,16 @@ describe('Page class', () => {
const expectedPath =
'pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-branches'
expect(
page.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion)
.href,
page!.permalinks.find(
(permalink: any) => permalink.pageVersion === nonEnterpriseDefaultVersion,
)!.href,
).toBe(`/en/${expectedPath}`)
expect(
page.permalinks.find(
(permalink) =>
page!.permalinks.find(
(permalink: any) =>
permalink.pageVersion ===
`enterprise-server@${enterpriseServerReleases.oldestSupported}`,
).href,
)!.href,
).toBe(`/en/enterprise-server@${enterpriseServerReleases.oldestSupported}/${expectedPath}`)
})
@@ -207,15 +218,15 @@ describe('Page class', () => {
languageCode: 'en',
})
expect(
page.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion)
.href,
page!.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion)
?.href,
).toBe('/en')
expect(
page.permalinks.find(
page!.permalinks.find(
(permalink) =>
permalink.pageVersion ===
`enterprise-server@${enterpriseServerReleases.oldestSupported}`,
).href,
)?.href,
).toBe(`/en/enterprise-server@${enterpriseServerReleases.oldestSupported}`)
})
@@ -226,15 +237,14 @@ describe('Page class', () => {
languageCode: 'en',
})
expect(
page.permalinks.find(
(permalink) =>
permalink.pageVersion === `enterprise-server@${enterpriseServerReleases.latest}`,
).href,
page!.permalinks.find(
(permalink: any) => permalink.pageVersion === `enterprise-server@${latest}`,
)!.href,
).toBe(
`/en/enterprise-server@${enterpriseServerReleases.latest}/products/admin/some-category/some-article`,
)
const pageVersions = page.permalinks.map((permalink) => permalink.pageVersion)
expect(pageVersions.length).toBeGreaterThan(1)
const pageVersions = page!.permalinks.map((permalink: any) => permalink.pageVersion)
expect(page!.permalinks.length).toBeGreaterThan(0)
expect(pageVersions.includes(nonEnterpriseDefaultVersion)).toBe(false)
})
@@ -245,15 +255,16 @@ describe('Page class', () => {
languageCode: 'en',
})
expect(
page.permalinks.find((permalink) => permalink.pageVersion === nonEnterpriseDefaultVersion)
.href,
page!.permalinks.find(
(permalink: any) => permalink.pageVersion === nonEnterpriseDefaultVersion,
)!.href,
).toBe('/en/products/actions/some-category/some-article')
expect(page.permalinks.length).toBe(1)
expect(page!.permalinks.length).toBe(1)
})
})
describe('videos', () => {
let page
let page: Page | undefined
beforeEach(async () => {
page = await Page.init({
@@ -264,7 +275,7 @@ describe('Page class', () => {
})
test('includes videos specified in the featuredLinks frontmatter', async () => {
expect(page.featuredLinks.videos).toStrictEqual([
expect((page as any)!.featuredLinks.videos).toStrictEqual([
{
title: 'codespaces',
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',
})
expect(page.introLinks).toStrictEqual({
expect(page!.introLinks).toStrictEqual({
overview: 'https://github.com',
'custom link!': 'https://github.com/features',
})
@@ -302,7 +313,7 @@ describe('Page class', () => {
test('throws an error on bad input', () => {
const markdown = null
expect(() => {
Page.parseFrontmatter('some/file.md', markdown)
;(Page as any).parseFrontmatter('some/file.md', markdown)
}).toThrow()
})
})
@@ -314,10 +325,10 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
expect(page.versions.fpt).toBe('*')
expect(page.versions.ghes).toBe('>3.0')
expect(page.applicableVersions.includes('free-pro-team@latest')).toBe(true)
expect(page.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
expect((page!.versions as any).fpt).toBe('*')
expect((page!.versions as any).ghes).toBe('>3.0')
expect(page!.applicableVersions.includes('free-pro-team@latest')).toBe(true)
expect(page!.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
})
test('index page', async () => {
@@ -326,7 +337,7 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../content'),
languageCode: 'en',
})
expect(page.versions).toEqual({ fpt: '*', ghec: '*', ghes: '*' })
expect(page!.versions).toEqual({ fpt: '*', ghec: '*', ghes: '*' })
})
test('enterprise admin index page', async () => {
@@ -336,8 +347,8 @@ describe('Page class', () => {
languageCode: 'en',
})
expect(nonEnterpriseDefaultPlan in page.versions).toBe(false)
expect(page.versions.ghes).toBe('*')
expect(nonEnterpriseDefaultPlan in page!.versions).toBe(false)
expect(page!.versions.ghes).toBe('*')
})
test('feature versions frontmatter', async () => {
@@ -361,9 +372,8 @@ describe('Page class', () => {
})
// Test the raw page data.
expect(page.versions.fpt).toBe('*')
expect(page.versions.ghes).toBe('>2.21')
expect(page.versions.ghae).toBeUndefined()
expect(page!.versions.fpt).toBe('*')
expect(page!.versions.ghes).toBe('>2.21')
// Test the resolved versioning, where GHES releases specified in frontmatter and in
// 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.
// 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`.
expect(page.applicableVersions.includes('free-pro-team@latest')).toBe(true)
expect(page.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
expect(page.applicableVersions.includes('feature')).toBe(false)
expect(page.applicableVersions.includes('placeholder')).toBe(false)
expect(page!.applicableVersions.includes('free-pro-team@latest')).toBe(true)
expect(page!.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
expect(page!.applicableVersions.includes('feature')).toBe(false)
expect(page!.applicableVersions.includes('placeholder')).toBe(false)
})
})
@@ -386,8 +396,8 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures/products'),
languageCode: 'en',
})
expect(page.defaultPlatform).toBeDefined()
expect(page.defaultPlatform).toBe('linux')
expect((page as any)!.defaultPlatform).toBeDefined()
expect((page as any)!.defaultPlatform).toBe('linux')
})
})
@@ -398,8 +408,8 @@ describe('Page class', () => {
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
expect(page.defaultTool).toBeDefined()
expect(page.defaultTool).toBe('cli')
expect((page as any)!.defaultTool).toBeDefined()
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'),
languageCode: 'en',
})
const context = {
const context: any = {
page: { version: `enterprise-server@3.2` },
currentVersion: `enterprise-server@3.2`,
currentVersionObj: {},
@@ -471,18 +481,18 @@ describe('catches errors thrown in Page class', () => {
fpt: false, // what the shortVersions contextualizer does
}
await page.render(context)
expect(page.product).toBe('')
expect(page.permissions).toBe('')
await page!.render(context)
expect(page!.product).toBe('')
expect(page!.permissions).toBe('')
// Change to FPT
context.page.version = nonEnterpriseDefaultVersion
context.version = nonEnterpriseDefaultVersion
context.currentPath = '/en/optional/attributes'
context.fpt = true
await page.render(context)
expect(page.product).toContain('FPT rulez!')
expect(page.permissions).toContain('FPT only!')
await page!.render(context)
expect(page!.product).toContain('FPT rulez!')
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 { beforeAll, describe, expect, test, vi } from 'vitest'
@@ -10,6 +11,11 @@ import {
makeLanguageSurrogateKey,
} from '@/frame/middleware/set-fastly-surrogate-key'
interface Category {
name: string
published_articles: string[]
}
describe('server', () => {
vi.setConfig({ testTimeout: 60 * 1000 })
@@ -81,11 +87,14 @@ describe('server', () => {
// it will render a very basic plain text 404 response.
const $ = await getDOM('/en/not-a-real-page', { allow404: true })
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(
$.text().includes(
'We track these errors automatically, but if the problem persists please feel free to contact us.',
),
($ as any)
.text()
.includes(
'We track these errors automatically, but if the problem persists please feel free to contact us.',
),
).toBe(true)
expect($.res.statusCode).toBe(404)
})
@@ -107,11 +116,14 @@ describe('server', () => {
test('renders a 500 page when errors are thrown', async () => {
const $ = await getDOM('/_500', { allow500s: true })
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(
$.text().includes(
'We track these errors automatically, but if the problem persists please feel free to contact us.',
),
($ as any)
.text()
.includes(
'We track these errors automatically, but if the problem persists please feel free to contact us.',
),
).toBe(true)
expect($.res.statusCode).toBe(500)
})
@@ -154,7 +166,7 @@ describe('server', () => {
const categories = JSON.parse(res.body)
expect(Array.isArray(categories)).toBe(true)
expect(categories.length).toBeGreaterThan(1)
categories.forEach((category) => {
categories.forEach((category: Category) => {
expect('name' 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 { languageKeys } from '@/languages/lib/languages'
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 host = `http://localhost:${port}`
const version = EnterpriseServerReleases.oldestSupported
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 MapObj = { [key: string]: string }

View File

@@ -34,7 +34,7 @@ describe('server rendering certain GraphQL pages', () => {
)
.filter(Boolean)
const nonFPTPermalinksHrefs = nonFPTPermalinks.map((permalink) => {
return permalink.href
return permalink!.href
})
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.
const track = getDataByLanguage(
`learning-tracks.${context.currentProduct}.${renderedTrackName}`,
context.currentLanguage,
context.currentLanguage!,
)
if (!track) {
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
// TODO: Once getDeepDataByLanguage is ported to TS
// 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') {
// 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
}
type FeaturedLinksExpanded = {
export type FeaturedLinksExpanded = {
[key: string]: FeaturedLinkExpanded[]
}