diff --git a/src/frame/components/context/CategoryLandingContext.tsx b/src/frame/components/context/CategoryLandingContext.tsx new file mode 100644 index 000000000000..fa33b2fff1f5 --- /dev/null +++ b/src/frame/components/context/CategoryLandingContext.tsx @@ -0,0 +1,62 @@ +import pick from 'lodash/pick' +import { createContext, useContext } from 'react' +import { LearningTrack } from './ArticleContext' +import { + FeaturedLink, + getFeaturedLinksFromReq, +} from 'src/landings/components/ProductLandingContext' + +export type TocItem = { + fullPath: string + title: string + intro?: string + childTocItems?: Array<{ + fullPath: string + title: string + intro: string + }> +} + +export type CategoryLandingContextT = { + title: string + intro: string + productCallout: string + permissions: string + tocItems: Array + variant?: 'compact' | 'expanded' + featuredLinks: Record> + renderedPage: string + currentLearningTrack?: LearningTrack + currentLayout: string +} + +export const CategoryLandingContext = createContext(null) + +export const useCategoryLandingContext = (): CategoryLandingContextT => { + const context = useContext(CategoryLandingContext) + + if (!context) { + throw new Error( + '"useCategoryLandingContext" may only be used inside "CategoryLandingContext.Provider"', + ) + } + + return context +} + +export const getCategoryLandingContextFromRequest = (req: any): CategoryLandingContextT => { + return { + title: req.context.page.title, + productCallout: req.context.page.product || '', + permissions: req.context.page.permissions || '', + intro: req.context.page.intro, + tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map((obj: any) => + pick(obj, ['fullPath', 'title', 'intro', 'childTocItems']), + ), + variant: req.context.genericTocFlat ? 'expanded' : 'compact', + featuredLinks: getFeaturedLinksFromReq(req), + renderedPage: req.context.renderedPage, + currentLearningTrack: req.context.currentLearningTrack, + currentLayout: req.context.currentLayoutName, + } +} diff --git a/src/frame/lib/frontmatter.js b/src/frame/lib/frontmatter.js index 49d409b857df..7fb0dfb2d35d 100644 --- a/src/frame/lib/frontmatter.js +++ b/src/frame/lib/frontmatter.js @@ -10,6 +10,7 @@ const layoutNames = [ 'product-guides', 'release-notes', 'inline', + 'category-landing', false, ] diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index 34053247c264..ed036cc53816 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -9,7 +9,11 @@ import findPageInSiteTree from '@/frame/lib/find-page-in-site-tree.js' export default async function genericToc(req: ExtendedRequest, res: Response, next: NextFunction) { if (!req.context) throw new Error('request not contextualized') if (!req.context.page) return next() - if (req.context.currentLayoutName !== 'default') return next() + if ( + req.context.currentLayoutName !== 'default' && + req.context.currentLayoutName !== 'category-landing' + ) + return next() // This middleware can only run on product, category, and map topics. if ( req.context.page.documentType === 'homepage' || @@ -92,7 +96,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne renderIntros = false req.context.genericTocNested = await getTocItems(treePage, req.context, { recurse: isRecursive, - renderIntros, + renderIntros: req.context.currentLayoutName === 'category-landing' ? true : false, includeHidden, }) } @@ -127,7 +131,11 @@ async function getTocItems(node: Tree, context: Context, opts: Options): Promise // Deliberately don't use `textOnly:true` here because we intend // to display the intro, in a table of contents component, // with the HTML (dangerouslySetInnerHTML). - intro = await page.renderProp('rawIntro', context) + intro = await page.renderProp( + 'rawIntro', + context, + context.currentLayoutName === 'category-landing' ? { textOnly: true } : {}, + ) } } diff --git a/src/landings/components/CategoryLanding.tsx b/src/landings/components/CategoryLanding.tsx new file mode 100644 index 000000000000..206aba4933b3 --- /dev/null +++ b/src/landings/components/CategoryLanding.tsx @@ -0,0 +1,67 @@ +import { useRouter } from 'next/router' +import cx from 'classnames' + +import { useCategoryLandingContext } from 'src/frame/components/context/CategoryLandingContext' +import { DefaultLayout } from 'src/frame/components/DefaultLayout' +import { ArticleTitle } from 'src/frame/components/article/ArticleTitle' +import { Lead } from 'src/frame/components/ui/Lead' +import { ClientSideRedirects } from 'src/rest/components/ClientSideRedirects' +import { RestRedirect } from 'src/rest/components/RestRedirect' +import { Breadcrumbs } from 'src/frame/components/page-header/Breadcrumbs' + +export const CategoryLanding = () => { + const router = useRouter() + const { title, intro, tocItems } = useCategoryLandingContext() + + // tocItems contains directories and its children, we only want the child articles + const onlyFlatItems = tocItems.flatMap((item) => item.childTocItems || []) + + return ( + + {router.route === '/[versionId]/rest/[category]' && } + {/* Doesn't matter *where* this is included because it will + never render anything. It always just return null. */} + + +
+
+ +
+ {title} + + {intro && {intro}} + +

Spotlight

+
+
Spotlight 1
+
Spotlight 2
+
Spotlight 3
+
+ +
+
+
+

Explore {onlyFlatItems.length} prompt articles

+
+
Searchbar
+
Category
+
Complexity
+
Industry
+
Reset
+
+
+ {/* TODO: replace with card components */} + {onlyFlatItems.map((item, index) => ( +
+
+
{item.title}
+
{item.intro}
+
+
+ ))} +
+
+
+
+ ) +} diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index 5f19a7e69760..d40871964167 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -35,11 +35,17 @@ import { ArticlePage } from 'src/frame/components/article/ArticlePage' import { ProductLanding } from 'src/landings/components/ProductLanding' import { ProductGuides } from 'src/landings/components/ProductGuides' import { TocLanding } from 'src/landings/components/TocLanding' +import { CategoryLanding } from 'src/landings/components/CategoryLanding' import { getTocLandingContextFromRequest, TocLandingContext, TocLandingContextT, } from 'src/frame/components/context/TocLandingContext' +import { + getCategoryLandingContextFromRequest, + CategoryLandingContext, + CategoryLandingContextT, +} from 'src/frame/components/context/CategoryLandingContext' import { useEffect } from 'react' function initiateArticleScripts() { @@ -54,6 +60,7 @@ type Props = { productGuidesContext?: ProductGuidesContextT tocLandingContext?: TocLandingContextT articleContext?: ArticleContextT + categoryLandingContext?: CategoryLandingContextT } const GlobalPage = ({ mainContext, @@ -61,6 +68,7 @@ const GlobalPage = ({ productGuidesContext, tocLandingContext, articleContext, + categoryLandingContext, }: Props) => { const router = useRouter() @@ -86,6 +94,12 @@ const GlobalPage = ({ ) + } else if (categoryLandingContext) { + content = ( + + + + ) } else if (tocLandingContext) { content = ( @@ -133,9 +147,13 @@ export const getServerSideProps: GetServerSideProps = async (context) => props.productGuidesContext = getProductGuidesContextFromRequest(req) additionalUINamespaces.push('product_guides') } else if (relativePath?.endsWith('index.md')) { - props.tocLandingContext = getTocLandingContextFromRequest(req) - if (props.tocLandingContext.currentLearningTrack?.trackName) { - additionalUINamespaces.push('learning_track_nav') + if (currentLayoutName === 'category-landing') { + props.categoryLandingContext = getCategoryLandingContextFromRequest(req) + } else { + props.tocLandingContext = getTocLandingContextFromRequest(req) + if (props.tocLandingContext.currentLearningTrack?.trackName) { + additionalUINamespaces.push('learning_track_nav') + } } } else if (props.mainContext.page) { // All articles that might have hover cards needs this diff --git a/src/search/lib/elasticsearch-versions.ts b/src/search/lib/elasticsearch-versions.ts index 7d6cb0dd070a..19dfd937d87c 100644 --- a/src/search/lib/elasticsearch-versions.ts +++ b/src/search/lib/elasticsearch-versions.ts @@ -45,6 +45,11 @@ for (const versionSource of Object.values(allVersions)) { } } +// Add the values to the keys as well so that the map value -> value works for versions that are already conformed to the indexVersion syntax +for (const [, value] of Object.entries(versionToIndexVersionMap)) { + versionToIndexVersionMap[value] = value +} + // All of the possible keys that can be input to access a version export const allIndexVersionKeys = Array.from( new Set([...Object.keys(versionToIndexVersionMap), ...Object.keys(allVersions)]), diff --git a/src/search/scripts/index/index-cli.ts b/src/search/scripts/index/index-cli.ts index 5ec770e8ef4a..dc4553d2877c 100644 --- a/src/search/scripts/index/index-cli.ts +++ b/src/search/scripts/index/index-cli.ts @@ -7,8 +7,8 @@ import { indexGeneralAutocomplete } from './lib/index-general-autocomplete' import { indexGeneralSearch } from './lib/index-general-search' import { allIndexVersionKeys, - allIndexVersionOptions, supportedAutocompletePlanVersions, + versionToIndexVersionMap, } from '@/search/lib/elasticsearch-versions' import { indexAISearchAutocomplete } from './lib/index-ai-search-autocomplete' @@ -17,13 +17,15 @@ dotenv.config() program.name('index').description('CLI scripts for indexing Docs data into Elasticsearch') +const allVersionKeysWithAll = [...allIndexVersionKeys, 'all'] + const generalAutoCompleteCommand = new Command('general-autocomplete') .description('Index for general search autocomplete') .addOption( new Option('-l, --language ', 'Specific languages(s)').choices(languageKeys), ) .addOption( - new Option('-v, --version ', 'Specific versions').choices(allIndexVersionKeys), + new Option('-v, --version ', 'Specific versions').choices(allVersionKeysWithAll), ) .option('--verbose', 'Verbose output') .option('--index-prefix ', 'Prefix for the index names', '') @@ -31,11 +33,24 @@ const generalAutoCompleteCommand = new Command('general-autocomplete') .action(async (dataRepoRoot: string, options) => { const languages = options.language ? options.language : languageKeys const indexPrefix = options.indexPrefix || '' + if (!Array.isArray(options.version)) { + if (typeof options.version === 'undefined') { + options.version = ['all'] + } else { + options.version = [options.version] + } + } + let versions = options.version + if (!versions.length || versions[0] === 'all') { + versions = supportedAutocompletePlanVersions + } else { + versions = versions.map((version: string) => versionToIndexVersionMap[version]) + } try { await indexGeneralAutocomplete({ dataRepoRoot, languages, - versions: options.version || supportedAutocompletePlanVersions, + versions, indexPrefix, }) } catch (error: any) { @@ -55,7 +70,7 @@ const generalSearchCommand = new Command('general-search') ) .option('-v, --verbose', 'Verbose outputs') .addOption( - new Option('-V, --version [VERSION...]', 'Specific versions').choices(allIndexVersionOptions), + new Option('-V, --version [VERSION...]', 'Specific versions').choices(allVersionKeysWithAll), ) .addOption( new Option('-l, --language ', 'Which languages to focus on').choices(languageKeys), @@ -123,7 +138,7 @@ const aiSearchAutocompleteCommand = new Command('ai-search-autocomplete') ).choices(['en']), ) .addOption( - new Option('-v, --version ', 'Specific versions').choices(allIndexVersionKeys), + new Option('-v, --version ', 'Specific versions').choices(allVersionKeysWithAll), ) .option('--verbose', 'Verbose output') .option('--index-prefix ', 'Prefix for the index names', '') @@ -133,11 +148,24 @@ const aiSearchAutocompleteCommand = new Command('ai-search-autocomplete') // Currently (since this is an experiment), we only support english const languages = ['en'] const indexPrefix = options.indexPrefix || '' + if (!Array.isArray(options.version)) { + if (typeof options.version === 'undefined') { + options.version = ['all'] + } else { + options.version = [options.version] + } + } + let versions = options.version + if (!versions.length || versions[0] === 'all') { + versions = supportedAutocompletePlanVersions + } else { + versions = versions.map((version: string) => versionToIndexVersionMap[version]) + } try { await indexAISearchAutocomplete({ dataRepoRoot, languages, - versions: options.version || supportedAutocompletePlanVersions, + versions, indexPrefix, }) } catch (error: any) { diff --git a/src/search/scripts/index/lib/index-ai-search-autocomplete.ts b/src/search/scripts/index/lib/index-ai-search-autocomplete.ts index 8bde681a1afe..109514e04898 100644 --- a/src/search/scripts/index/lib/index-ai-search-autocomplete.ts +++ b/src/search/scripts/index/lib/index-ai-search-autocomplete.ts @@ -29,6 +29,12 @@ export async function indexAISearchAutocomplete(options: Options) { const client = getElasticsearchClient(undefined, options.verbose) await client.ping() // Will throw if not available + console.log( + 'Indexing AI search autocomplete for languages: %O and versions: %O', + options.languages, + options.versions, + ) + const { dataRepoRoot, languages, versions } = options for (const language of languages) { for (const version of versions) { diff --git a/src/search/scripts/index/lib/index-general-autocomplete.ts b/src/search/scripts/index/lib/index-general-autocomplete.ts index 436417256ecf..0d2eeb036f7e 100644 --- a/src/search/scripts/index/lib/index-general-autocomplete.ts +++ b/src/search/scripts/index/lib/index-general-autocomplete.ts @@ -29,6 +29,12 @@ export async function indexGeneralAutocomplete(options: Options) { const client = getElasticsearchClient(undefined, options.verbose) await client.ping() // Will throw if not available + console.log( + 'Indexing general autocomplete for languages: %O and versions: %O', + options.languages, + options.versions, + ) + const { dataRepoRoot, versions, languages } = options for (const language of languages) { for (const version of versions) { diff --git a/src/search/scripts/index/lib/index-general-search.ts b/src/search/scripts/index/lib/index-general-search.ts index 7a6596a096e9..c5d00b0b3540 100644 --- a/src/search/scripts/index/lib/index-general-search.ts +++ b/src/search/scripts/index/lib/index-general-search.ts @@ -1,8 +1,6 @@ import { Client } from '@elastic/elasticsearch' -import chalk from 'chalk' import { languageKeys } from '#src/languages/lib/languages.js' -import { allVersions } from '#src/versions/lib/all-versions.js' import { getElasticSearchIndex } from '@/search/lib/elasticsearch-indexes' import { getElasticsearchClient } from '@/search/lib/helpers/get-client' import { @@ -16,12 +14,14 @@ import { import { sleep } from '@/search/lib/helpers/time' import { getGeneralSearchSettings } from '@/search/scripts/index/utils/settings' import { generalSearchMappings } from '@/search/scripts/index/utils/mappings' - -import type { AllVersionInfo } from '@/search/scripts/index/types' +import { + allIndexVersionOptions, + versionToIndexVersionMap, +} from '@/search/lib/elasticsearch-versions' interface Options { verbose?: boolean - version?: string[] | string + version?: string | undefined language?: string[] notLanguage?: string[] elasticsearchUrl?: string @@ -31,17 +31,6 @@ interface Options { sleepTime: number } -const shortNames: { [key: string]: AllVersionInfo } = Object.fromEntries( - Object.values(allVersions).map((info: AllVersionInfo) => { - const shortName = info.hasNumberedReleases - ? info.miscBaseName + info.currentRelease - : info.miscBaseName - return [shortName, info] - }), -) - -const allVersionKeys = Object.keys(shortNames) - export async function indexGeneralSearch(sourceDirectory: string, opts: Options) { if (!sourceDirectory) { throw new Error('Must pass the source directory as the first argument') @@ -56,29 +45,62 @@ export async function indexGeneralSearch(sourceDirectory: string, opts: Options) const client = getElasticsearchClient(opts.elasticsearchUrl, opts.verbose) await client.ping() // Will throw if not available - let version: string | string[] | undefined = opts.version - if (!version && process.env.VERSION && process.env.VERSION !== 'all') { - version = process.env.VERSION - if (!allVersionKeys.includes(version)) { - throw new Error( - `Environment variable 'VERSION' (${version}) is not recognized. Must be one of ${allVersionKeys}`, + let versions: string[] | 'all' = [] + if ('version' in opts) { + if (process.env.VERSION) { + console.warn( + `'version' specified as argument ('${versions}') AND environment variable ('${process.env.VERSION}')`, ) } + if (!Array.isArray(opts.version)) { + if (typeof opts.version === 'undefined') { + versions = 'all' + } else { + versions = [opts.version] + } + } else if (opts.version[0] === 'all') { + versions = 'all' + } else { + versions = opts.version + } + } else if (process.env.VERSION) { + if (process.env.VERSION !== 'all') { + versions = [process.env.VERSION] + } else { + versions = 'all' + } } - let versionKeys = allVersionKeys - if (version) { - versionKeys = Array.isArray(version) ? version : [version] + + // Validate + if (versions !== 'all') { + for (const version of versions) { + if (!allIndexVersionOptions.includes(version || '')) { + throw new Error( + `Argument -version ${version} is not recognized. Must be one of ${allIndexVersionOptions}`, + ) + } + } + } + + let versionsToIndex: string[] = [] + if (!versions.length || versions === 'all') { + versionsToIndex = allIndexVersionOptions + } else if (versions.length) { + versionsToIndex = versions.map((version) => versionToIndexVersionMap[version]) } const languages = language || languageKeys.filter((lang) => !notLanguage || !notLanguage.includes(lang)) - if (opts.verbose) { - console.log(`Indexing on languages ${chalk.bold(languages.join(', '))}`) - } + + console.log( + 'Indexing general search for languages: %O and versions: %O', + languages, + versionsToIndex, + ) for (const language of languages) { let count = 0 - for (const versionKey of versionKeys) { + for (const versionKey of versionsToIndex) { const startTime = new Date() const { indexName, indexAlias } = getElasticSearchIndex( @@ -91,7 +113,7 @@ export async function indexGeneralSearch(sourceDirectory: string, opts: Options) await indexVersion(client, indexName, indexAlias, language, sourceDirectory, opts) count++ - if (opts.staggerSeconds && count < versionKeys.length - 1) { + if (opts.staggerSeconds && count < versionsToIndex.length - 1) { console.log(`Sleeping for ${opts.staggerSeconds} seconds...`) await sleep(1000 * opts.staggerSeconds) } diff --git a/src/search/scripts/index/types.ts b/src/search/scripts/index/types.ts index bb4fd8f876fe..eaaa82ae0e2d 100644 --- a/src/search/scripts/index/types.ts +++ b/src/search/scripts/index/types.ts @@ -3,18 +3,6 @@ export type RetryConfig = { sleepTime: number } -export interface AllVersionInfo { - hasNumberedReleases: boolean - miscBaseName: string - currentRelease: string - version: string - plan: string -} - -export interface AllVersions { - [key: string]: AllVersionInfo -} - export interface Options { language?: string notLanguage?: string diff --git a/src/search/scripts/scrape/scrape-cli.ts b/src/search/scripts/scrape/scrape-cli.ts index db8c89e4a0a0..362a29574631 100644 --- a/src/search/scripts/scrape/scrape-cli.ts +++ b/src/search/scripts/scrape/scrape-cli.ts @@ -7,7 +7,6 @@ import { program, Option } from 'commander' import { languageKeys } from '@/languages/lib/languages' import scrapeIntoIndexJson from '@/search/scripts/scrape/lib/scrape-into-index-json' import { - allIndexVersionKeys, allIndexVersionOptions, versionToIndexVersionMap, } from '@/search/lib/elasticsearch-versions' @@ -18,7 +17,10 @@ program .description('Creates search index JSONs by scraping a running docs site') .option('-v, --verbose', 'Verbose outputs') .addOption( - new Option('-V, --version ', 'Specific versions').choices(allIndexVersionOptions), + new Option('-V, --version ', 'Specific versions').choices([ + ...allIndexVersionOptions, + 'all', + ]), ) .addOption( new Option('-l, --language ', 'Which languages to focus on').choices(languageKeys), @@ -61,25 +63,35 @@ async function main(opts: ProgramOptions, args: string[]) { throw new Error("Can't specify --language *and* --not-language") } - let version: string | undefined + let indexVersion: string | undefined if ('version' in opts) { - version = opts.version + indexVersion = opts.version if (process.env.VERSION) { console.warn( - `'version' specified as argument ('${version}') AND environment variable ('${process.env.VERSION}')`, + `'version' specified as argument ('${indexVersion}') AND environment variable ('${process.env.VERSION}')`, ) } - } else { - if (process.env.VERSION && process.env.VERSION !== 'all') { - version = process.env.VERSION - if (!allIndexVersionOptions.includes(version)) { - throw new Error( - `Environment variable 'VERSION' (${version}) is not recognized. Must be one of ${allIndexVersionOptions}`, - ) - } + if (!allIndexVersionOptions.includes(indexVersion || '') && indexVersion !== 'all') { + throw new Error( + `Argument -version (${indexVersion}) is not recognized. Must be one of ${allIndexVersionOptions}`, + ) + } + } else if (process.env.VERSION && process.env.VERSION !== 'all') { + indexVersion = process.env.VERSION + if (!allIndexVersionOptions.includes(indexVersion || '')) { + throw new Error( + `Environment variable 'VERSION' (${indexVersion}) is not recognized. Must be one of ${allIndexVersionOptions}`, + ) } } + let versionsToBuild: string[] = [] + if (!indexVersion || indexVersion === 'all') { + versionsToBuild = allIndexVersionOptions + } else if (indexVersion) { + versionsToBuild = [versionToIndexVersionMap[indexVersion]] + } + let docsInternalDataPath: string | undefined const { docsInternalData } = opts const { DOCS_INTERNAL_DATA } = process.env @@ -109,16 +121,6 @@ async function main(opts: ProgramOptions, args: string[]) { throw new Error(`'${docsInternalDataPath}' must contain a 'hydro' directory`) } - let indexVersion: string | undefined - if (version && version !== 'all') { - indexVersion = versionToIndexVersionMap[version] - } - if (!indexVersion && !allIndexVersionOptions.includes(indexVersion || '')) { - throw new Error( - `Input error. Version must be not passed or one of ${allIndexVersionOptions}. Got: ${indexVersion}`, - ) - } - const [outDirectory] = args const config: Config = { @@ -132,7 +134,7 @@ async function main(opts: ProgramOptions, args: string[]) { notLanguage, outDirectory, config, - versionsToBuild: indexVersion ? [indexVersion] : Object.keys(allIndexVersionKeys), + versionsToBuild, } await scrapeIntoIndexJson(options) }