diff --git a/content/actions/how-tos/write-workflows/choose-what-workflows-do/pass-job-outputs.md b/content/actions/how-tos/write-workflows/choose-what-workflows-do/pass-job-outputs.md index 568724685381..06f54ce4c288 100644 --- a/content/actions/how-tos/write-workflows/choose-what-workflows-do/pass-job-outputs.md +++ b/content/actions/how-tos/write-workflows/choose-what-workflows-do/pass-job-outputs.md @@ -23,8 +23,8 @@ redirect_from: job1: runs-on: ubuntu-latest outputs: - output1: ${{ steps.step1.outputs.test }} - output2: ${{ steps.step2.outputs.test }} + output1: {% raw %}${{ steps.step1.outputs.test }}{% endraw %} + output2: {% raw %}${{ steps.step2.outputs.test }}{% endraw %} steps: - id: step1 run: echo "test=hello" >> "$GITHUB_OUTPUT" @@ -52,8 +52,8 @@ redirect_from: needs: job1 steps: - env: - OUTPUT1: ${{needs.job1.outputs.output1}} - OUTPUT2: ${{needs.job1.outputs.output2}} + OUTPUT1: {% raw %}${{needs.job1.outputs.output1}}{% endraw %} + OUTPUT2: {% raw %}${{needs.job1.outputs.output2}}{% endraw %} run: echo "$OUTPUT1 $OUTPUT2" ``` diff --git a/content/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors.md b/content/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors.md index 67409e16d30a..396864b3f4bf 100644 --- a/content/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors.md +++ b/content/communities/setting-up-your-project-for-healthy-contributions/setting-guidelines-for-repository-contributors.md @@ -17,6 +17,11 @@ shortTitle: Contributor guidelines To help your project contributors do good work, you can add a file with contribution guidelines to your project repository's root, `docs`, or `.github` folder. When someone opens a pull request or creates an issue, they will see a link to that file. {% ifversion fpt or ghec %}The link to the contributing guidelines also appears on your repository's `contribute` page. For an example of a `contribute` page, see [github/docs/contribute](https://github.com/github/docs/contribute).{% endif %} +{% ifversion fpt or ghec or ghes > 3.18 %}If your repository includes a `CONTRIBUTING.md` file, {% data variables.product.github %} also surfaces it in two other places to make it easier for contributors to discover: + +* A "{% octicon "people" aria-hidden="true" aria-label="people" %} Contributing" tab in the repository overview (next to the "{% octicon "book" aria-hidden="true" aria-label="book" %} README" and "{% octicon "code-of-conduct" aria-hidden="true" aria-label="code-of-conduct" %} Code of conduct") +* A "Contributing" link in the repository sidebar{% endif %} + For the repository owner, contribution guidelines are a way to communicate how people should contribute. For contributors, the guidelines help them verify that they're submitting well-formed pull requests and opening useful issues. @@ -28,7 +33,7 @@ You can create default contribution guidelines for your organization or personal > [!TIP] > Repository maintainers can set specific guidelines for issues by creating an issue or pull request template for the repository. For more information, see [AUTOTITLE](/communities/using-templates-to-encourage-useful-issues-and-pull-requests/about-issue-and-pull-request-templates). -## Adding a _CONTRIBUTING_ file +## Adding a `CONTRIBUTING.md` file {% data reusables.repositories.navigate-to-repo %} {% data reusables.files.add-file %} diff --git a/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md b/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md index 59fcf89afd41..5e71b83b9a5e 100644 --- a/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md +++ b/content/contributing/writing-for-github-docs/using-yaml-frontmatter.md @@ -102,7 +102,7 @@ For more information, see [AUTOTITLE](/contributing/syntax-and-versioning-for-gi * Purpose: Set a human-friendly title for use in the rendered page's `` tag and an `h1` element at the top of the page. * Type: `String` -* Optional. If omitted, the page `<title>` will still be set, albeit with a generic value like `GitHub.com` or `GitHub Enterprise`. +* **Required**. ### `shortTitle` diff --git a/data/ui.yml b/data/ui.yml index 319655cefa7e..d749d965157a 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -260,6 +260,17 @@ footer: expert_services: Expert services blog: Blog machine: Some of this content may be machine- or AI-translated. +bespoke_landing: + articles: Articles + all_categories: All categories + search_articles: Search articles +discovery_landing: + recommended: Recommended + articles: Articles + all_categories: All categories + search_articles: Search articles +journey_landing: + articles: '{{ number }} Articles' product_landing: quickstart: Quickstart reference: Reference diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index 319655cefa7e..d749d965157a 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -260,6 +260,17 @@ footer: expert_services: Expert services blog: Blog machine: Some of this content may be machine- or AI-translated. +bespoke_landing: + articles: Articles + all_categories: All categories + search_articles: Search articles +discovery_landing: + recommended: Recommended + articles: Articles + all_categories: All categories + search_articles: Search articles +journey_landing: + articles: '{{ number }} Articles' product_landing: quickstart: Quickstart reference: Reference diff --git a/src/frame/components/ui/ScrollButton/ScrollButton.tsx b/src/frame/components/ui/ScrollButton/ScrollButton.tsx index 59149ebde57e..6cce1a4e62a1 100644 --- a/src/frame/components/ui/ScrollButton/ScrollButton.tsx +++ b/src/frame/components/ui/ScrollButton/ScrollButton.tsx @@ -18,6 +18,14 @@ export const ScrollButton = ({ className, ariaLabel }: ScrollButtonPropsT) => { // We cannot determine document.documentElement.scrollTop height because we set the height: 100vh and set overflow to auto to keep the header sticky // That means window.scrollTop height is always 0 // Using IntersectionObserver we can determine if the h1 header is in view or not. If not, we show the scroll to top button, if so, we hide it + const h1Element = document.getElementsByTagName('h1')[0] + if (!h1Element) { + if (process.env.NODE_ENV !== 'production') { + throw new Error('No h1 element found in the document.') + } + return + } + const observer = new IntersectionObserver( function (entries) { if (entries[0].isIntersecting === false) { @@ -28,7 +36,7 @@ export const ScrollButton = ({ className, ariaLabel }: ScrollButtonPropsT) => { }, { threshold: [0] }, ) - observer.observe(document.getElementsByTagName('h1')[0]) + observer.observe(h1Element) return () => { observer.disconnect() } diff --git a/src/frame/lib/frontmatter.js b/src/frame/lib/frontmatter.js index 1483278ec3f7..013fe4482c32 100644 --- a/src/frame/lib/frontmatter.js +++ b/src/frame/lib/frontmatter.js @@ -14,6 +14,9 @@ const layoutNames = [ 'release-notes', 'inline', 'category-landing', + 'bespoke-landing', + 'discovery-landing', + 'journey-landing', false, ] @@ -325,6 +328,11 @@ export const schema = { }, description: 'Array of articles to feature in the spotlight section', }, + // Recommended configuration for category landing pages + recommended: { + type: 'array', + description: 'Array of articles to feature in the carousel section', + }, }, } diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index c15a74eaa146..948a2d712f51 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -3,6 +3,24 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest, Context, Tree, ToC } from '@/types' import findPageInSiteTree from '@/frame/lib/find-page-in-site-tree' +function isNewLandingPage(currentLayoutName: string): boolean { + return ( + currentLayoutName === 'category-landing' || + currentLayoutName === 'bespoke_landing' || + currentLayoutName === 'discovery_landing' || + currentLayoutName === 'journey_landing' + ) +} + +// TODO: TEMP: This is a temporary solution to turn off/on new landing pages while we develop them. +function isNewLandingPageFeature(req: ExtendedRequest): boolean { + return ( + req.query?.feature === 'bespoke-landing' || + req.query?.feature === 'journey-landing' || + req.query?.feature === 'discovery-landing' + ) +} + // This module adds either flatTocItems or nestedTocItems to the context object for // product, category, and subcategory TOCs that don't have other layouts specified. // They are rendered by includes/generic-toc-flat.html or includes/generic-toc-nested.html. @@ -10,8 +28,9 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne if (!req.context) throw new Error('request not contextualized') if (!req.context.page) return next() if ( + !isNewLandingPageFeature(req) && req.context.currentLayoutName !== 'default' && - req.context.currentLayoutName !== 'category-landing' + !isNewLandingPage(req.context.currentLayoutName || '') ) return next() // This middleware can only run on product, category, and subcategories. @@ -96,7 +115,10 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne renderIntros = false req.context.genericTocNested = await getTocItems(treePage, req.context, { recurse: isRecursive, - renderIntros: req.context.currentLayoutName === 'category-landing' ? true : false, + renderIntros: + isNewLandingPageFeature(req) || isNewLandingPage(req.context.currentLayoutName || '') + ? true + : false, includeHidden, }) } diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx new file mode 100644 index 000000000000..29746737c2dc --- /dev/null +++ b/src/landings/components/bespoke/BespokeLanding.tsx @@ -0,0 +1,29 @@ +import { useMemo } from 'react' + +import { DefaultLayout } from '@/frame/components/DefaultLayout' +import { useBespokeContext } from '@/landings/context/BespokeContext' +import { LandingHero } from '@/landings/components/shared/LandingHero' +import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter' + +import type { ArticleCardItems } from '@/landings/types' + +export const BespokeLanding = () => { + const { title, intro, tocItems } = useBespokeContext() + + const flatArticles: ArticleCardItems = useMemo( + () => tocItems.flatMap((item) => item.childTocItems || []), + [tocItems], + ) + + return ( + <DefaultLayout> + <div data-search="article-body"> + <LandingHero title={title} intro={intro} /> + + <div data-search="hide"> + <ArticleGrid flatArticles={flatArticles} /> + </div> + </div> + </DefaultLayout> + ) +} diff --git a/src/landings/components/discovery/DiscoveryLanding.tsx b/src/landings/components/discovery/DiscoveryLanding.tsx new file mode 100644 index 000000000000..4bd247378cf5 --- /dev/null +++ b/src/landings/components/discovery/DiscoveryLanding.tsx @@ -0,0 +1,28 @@ +import { useMemo } from 'react' + +import { DefaultLayout } from '@/frame/components/DefaultLayout' +import { useDiscoveryContext } from '@/landings/context/DiscoveryContext' +import { LandingHero } from '@/landings/components/shared/LandingHero' +import { ArticleGrid } from '@/landings/components/shared/LandingArticleGridWithFilter' +import { LandingCarousel } from '@/landings/components/shared/LandingCarousel' + +import type { ArticleCardItems } from '@/landings/types' + +export const DiscoveryLanding = () => { + const { title, intro, tocItems, recommended } = useDiscoveryContext() + + const flatArticles: ArticleCardItems = useMemo( + () => tocItems.flatMap((item) => item.childTocItems || []), + [tocItems], + ) + + return ( + <DefaultLayout> + <div> + <LandingHero title={title} intro={intro} /> + <LandingCarousel flatArticles={flatArticles} recommended={recommended} /> + <ArticleGrid flatArticles={flatArticles} /> + </div> + </DefaultLayout> + ) +} diff --git a/src/landings/components/journey/JourneyLanding.tsx b/src/landings/components/journey/JourneyLanding.tsx new file mode 100644 index 000000000000..9ce831ef7610 --- /dev/null +++ b/src/landings/components/journey/JourneyLanding.tsx @@ -0,0 +1,17 @@ +import { DefaultLayout } from '@/frame/components/DefaultLayout' +import { useJourneyContext } from '@/landings/context/JourneyContext' +import { LandingHero } from '@/landings/components/shared/LandingHero' + +export const JourneyLanding = () => { + const { title, intro } = useJourneyContext() + + return ( + <DefaultLayout> + <div> + <LandingHero title={title} intro={intro} /> + + <div>TODO</div> + </div> + </DefaultLayout> + ) +} diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss new file mode 100644 index 000000000000..3729fb1b82f3 --- /dev/null +++ b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss @@ -0,0 +1,36 @@ +@import "@primer/css/support/variables/layout.scss"; +@import "@primer/css/support/mixins/layout.scss"; + +.articleGrid { + display: grid; + gap: 1.5rem; + + // Mobile: 1 column + grid-template-columns: 1fr; + + // Tablet: 2 columns + @include breakpoint(md) { + grid-template-columns: repeat(2, 1fr); + } + + // Desktop: 3 columns + @include breakpoint(lg) { + grid-template-columns: repeat(3, 1fr); + } +} + +.articleCard { + display: flex; + flex-direction: column; + height: 100%; +} + +.cardContent { + display: flex; + flex-direction: column; + height: 100%; +} + +.cardFooter { + margin-top: auto; +} diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.tsx b/src/landings/components/shared/LandingArticleGridWithFilter.tsx new file mode 100644 index 000000000000..df6f5798812d --- /dev/null +++ b/src/landings/components/shared/LandingArticleGridWithFilter.tsx @@ -0,0 +1,210 @@ +import { useState, useRef } from 'react' +import { TextInput, ActionMenu, ActionList, Button, Box } from '@primer/react' +import { SearchIcon } from '@primer/octicons-react' +import cx from 'classnames' + +import { Link } from '@/frame/components/Link' +import { ArticleCardItems, ChildTocItem } from '@/landings/types' +import { getOcticonComponent } from '@/landings/lib/octicons' + +import styles from './LandingArticleGridWithFilter.module.scss' + +type ArticleGridProps = { + flatArticles: ArticleCardItems +} + +export const ArticleGrid = ({ flatArticles }: ArticleGridProps) => { + const [searchQuery, setSearchQuery] = useState('') + const [selectedCategory, setSelectedCategory] = useState('All') + const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0) + + const inputRef = useRef<HTMLInputElement>(null) + + // Extract unique categories from the articles + const categories: string[] = [ + 'All', + ...new Set(flatArticles.flatMap((item) => item.category || [])), + ] + + const applyFilters = () => { + let results = flatArticles + + if (searchQuery) { + results = results.filter((token) => { + return Object.values(token).some((value) => { + if (typeof value === 'string') { + return value.toLowerCase().includes(searchQuery.toLowerCase()) + } else if (Array.isArray(value)) { + return value.some((item) => { + if (typeof item === 'string') { + return item.toLowerCase().includes(searchQuery.toLowerCase()) + } + }) + } + return false + }) + }) + } + + if (selectedCategory !== 'All') { + results = results.filter((item) => item.category?.includes(selectedCategory)) + } + + return results + } + + const filteredResults = applyFilters() + + const handleSearch = (query: string) => { + setSearchQuery(query) + } + + const handleFilter = (option: string, index: number) => { + setSelectedCategory(option) + setSelectedCategoryIndex(index) + } + + const handleResetFilter = () => { + setSearchQuery('') + setSelectedCategory('All') + setSelectedCategoryIndex(0) + if (inputRef.current) { + inputRef.current.value = '' + } + } + + return ( + <div> + <h2 + style={{ + marginTop: '3rem', + }} + > + TODO: Article grid placeholder + </h2> + {/* Filter and Search Controls */} + <div className="d-lg-flex d-sm-block border-bottom pb-4 mb-4"> + <div className="col-12 mr-2"> + <form onSubmit={(e) => e.preventDefault()}> + <TextInput + leadingVisual={SearchIcon} + className="m-1" + sx={{ minWidth: ['stretch', 'stretch', 'stretch', 'stretch'] }} + placeholder="Search articles..." + ref={inputRef} + autoComplete="false" + onChange={(e) => { + const query = e.target.value || '' + handleSearch(query) + }} + /> + </form> + </div> + <div className="d-flex flex-wrap flex-md-nowrap"> + <ActionMenu> + <ActionMenu.Button className="col-md-1 col-sm-2 m-1"> + <Box + sx={{ + color: 'fg.muted', + display: 'inline-block', + }} + > + Category: + </Box>{' '} + {categories[selectedCategoryIndex]} + </ActionMenu.Button> + <ActionMenu.Overlay width="auto"> + <ActionList selectionVariant="single"> + {categories.map((category, index) => ( + <ActionList.Item + key={index} + selected={index === selectedCategoryIndex} + onSelect={() => handleFilter(category, index)} + > + {category} + </ActionList.Item> + ))} + </ActionList> + </ActionMenu.Overlay> + </ActionMenu> + + <Button + variant="invisible" + className="col-md-1 col-sm-2 mt-1" + onClick={handleResetFilter} + > + Reset filters + </Button> + </div> + </div> + + {/* Results Grid */} + <div className={styles.articleGrid}> + {filteredResults.map((article, index) => ( + <ArticleCard key={index} article={article} /> + ))} + {filteredResults.length === 0 && ( + <div className="col-12 text-center p-4"> + <p className="color-fg-muted">No articles found matching your criteria.</p> + </div> + )} + </div> + </div> + ) +} + +type ArticleCardProps = { + article: ChildTocItem +} + +const ArticleCard = ({ article }: ArticleCardProps) => { + const IconComponent = getOcticonComponent(article.octicon || undefined) + + return ( + <Box className={cx(styles.articleCard, 'Box p-4')} sx={{ height: '100%' }}> + <div className={styles.cardContent}> + <div className="d-flex flex-items-start mb-3"> + {IconComponent && ( + <IconComponent size={24} className="mr-3 mt-1 color-fg-accent flex-shrink-0" /> + )} + <div className="flex-1"> + <h3 className="h4 mb-2"> + <Link href={article.fullPath} className="color-fg-default"> + {article.title} + </Link> + </h3> + {article.intro && <p className="color-fg-muted mb-3 f6">{article.intro}</p>} + </div> + </div> + + {/* Categories */} + {article.category && article.category.length > 0 && ( + <div className="d-flex flex-wrap gap-1 mb-3"> + {article.category.map((cat, index) => ( + <span key={index} className="Label Label--accent Label--small"> + {cat} + </span> + ))} + </div> + )} + + {/* Complexity */} + {article.complexity && article.complexity.length > 0 && ( + <div className="d-flex flex-wrap gap-1 mb-3"> + {article.complexity.map((comp, index) => ( + <span key={index} className="Label Label--success Label--small"> + {comp} + </span> + ))} + </div> + )} + + <div className={styles.cardFooter}> + <Link href={article.fullPath} className="btn-link f6"> + Read article → + </Link> + </div> + </div> + </Box> + ) +} diff --git a/src/landings/components/shared/LandingCarousel.tsx b/src/landings/components/shared/LandingCarousel.tsx new file mode 100644 index 000000000000..46082777abfe --- /dev/null +++ b/src/landings/components/shared/LandingCarousel.tsx @@ -0,0 +1,50 @@ +import type { TocItem } from '@/landings/types' + +type LandingCarouselProps = { + recommended?: string[] // Array of article paths + flatArticles: TocItem[] +} + +export const LandingCarousel = ({ flatArticles, recommended }: LandingCarouselProps) => { + // Helper function to find article data from tocItems + const findArticleData = (articlePath: string) => { + const cleanPath = articlePath.startsWith('/') ? articlePath.slice(1) : articlePath + return flatArticles.find( + (item) => item.fullPath && cleanPath.split('/').pop() === item.fullPath.split('/').pop(), + ) + } + + // Process recommended items to get article data + const processedRecommendedItems = + recommended?.map((recommendedArticlePath) => { + const articleData = findArticleData(recommendedArticlePath) + return { + article: recommendedArticlePath, + title: articleData?.title || 'Unknown Article', + description: articleData?.intro || '', + url: articleData?.fullPath || recommendedArticlePath, + } + }) || [] + + return ( + <div> + <h2 + style={{ + marginTop: '3rem', + }} + > + TODO: Carousel placeholder + </h2> + <ul> + {processedRecommendedItems.map((article) => ( + <li key={article.article || article.title}> + <a href={article.url}> + <h2>{article.title}</h2> + </a> + <p>{article.description}</p> + </li> + ))} + </ul> + </div> + ) +} diff --git a/src/landings/components/shared/LandingHero.tsx b/src/landings/components/shared/LandingHero.tsx new file mode 100644 index 000000000000..fc717b2799a7 --- /dev/null +++ b/src/landings/components/shared/LandingHero.tsx @@ -0,0 +1,18 @@ +import { Lead } from '@/frame/components/ui/Lead/Lead' + +type LandingHeroProps = { + title: string + intro?: string +} + +export const LandingHero = ({ title, intro }: LandingHeroProps) => { + return ( + <header> + <div> + <h1>TODO: Landing hero placeholder</h1> + <h2>{title}</h2> + {intro && <Lead>{intro}</Lead>} + </div> + </header> + ) +} diff --git a/src/landings/context/BespokeContext.tsx b/src/landings/context/BespokeContext.tsx new file mode 100644 index 000000000000..f7bd5b9ca940 --- /dev/null +++ b/src/landings/context/BespokeContext.tsx @@ -0,0 +1,49 @@ +import { createContext, useContext } from 'react' +import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' +import { mapRawTocItemToTocItem } from '@/landings/types' +import type { TocItem } from '@/landings/types' +import type { LearningTrack } from '@/types' + +export type BespokeContextT = { + title: string + intro: string + productCallout: string + permissions: string + tocItems: Array<TocItem> + variant?: 'compact' | 'expanded' + featuredLinks: Record<string, Array<FeaturedLink>> + renderedPage: string + currentLearningTrack?: LearningTrack + currentLayout: string +} + +export const BespokeContext = createContext<BespokeContextT | null>(null) + +export const useBespokeContext = (): BespokeContextT => { + const context = useContext(BespokeContext) + + if (!context) { + throw new Error('"useBespokeContext" may only be used inside "BespokeContext.Provider"') + } + + return context +} + +export const getBespokeContextFromRequest = async (req: any): Promise<BespokeContextT> => { + const page = req.context.page + + return { + title: page.title, + productCallout: page.product || '', + permissions: page.permissions || '', + intro: page.intro, + tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( + mapRawTocItemToTocItem, + ), + 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/landings/context/DiscoveryContext.tsx b/src/landings/context/DiscoveryContext.tsx new file mode 100644 index 000000000000..c4b13f2702d6 --- /dev/null +++ b/src/landings/context/DiscoveryContext.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from 'react' +import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' +import { mapRawTocItemToTocItem } from '@/landings/types' +import type { TocItem } from '@/landings/types' +import type { LearningTrack } from '@/types' + +export type DiscoveryContextT = { + title: string + intro: string + productCallout: string + permissions: string + tocItems: Array<TocItem> + variant?: 'compact' | 'expanded' + featuredLinks: Record<string, Array<FeaturedLink>> + renderedPage: string + currentLearningTrack?: LearningTrack + currentLayout: string + recommended?: string[] // Array of article paths +} + +export const DiscoveryContext = createContext<DiscoveryContextT | null>(null) + +export const useDiscoveryContext = (): DiscoveryContextT => { + const context = useContext(DiscoveryContext) + + if (!context) { + throw new Error('"useDiscoveryContext" may only be used inside "DiscoveryContext.Provider"') + } + + return context +} + +export const getDiscoveryContextFromRequest = async (req: any): Promise<DiscoveryContextT> => { + const page = req.context.page + + // Support legacy `spotlight` property as `recommended` for pages like Copilot Cookbook + // However, `spotlight` will have lower priority than the `recommended` property + let recommended: string[] = [] + if (page.recommended && page.recommended.length > 0) { + recommended = page.recommended + } else if (page.spotlight && page.spotlight.length > 0) { + // Remove the `image` property from spotlight items, since we don't use those for the carousel + recommended = page.spotlight.map((item: any) => item.article) + } + + return { + title: page.title, + productCallout: page.product || '', + permissions: page.permissions || '', + intro: page.intro, + tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( + mapRawTocItemToTocItem, + ), + variant: req.context.genericTocFlat ? 'expanded' : 'compact', + featuredLinks: getFeaturedLinksFromReq(req), + renderedPage: req.context.renderedPage, + currentLearningTrack: req.context.currentLearningTrack, + currentLayout: req.context.currentLayoutName, + recommended, + } +} diff --git a/src/landings/context/JourneyContext.tsx b/src/landings/context/JourneyContext.tsx new file mode 100644 index 000000000000..47c0c8340172 --- /dev/null +++ b/src/landings/context/JourneyContext.tsx @@ -0,0 +1,49 @@ +import { createContext, useContext } from 'react' +import { FeaturedLink, getFeaturedLinksFromReq } from '@/landings/components/ProductLandingContext' +import { mapRawTocItemToTocItem } from '@/landings/types' +import type { TocItem } from '@/landings/types' +import type { LearningTrack } from '@/types' + +export type JourneyContextT = { + title: string + intro: string + productCallout: string + permissions: string + tocItems: Array<TocItem> + variant?: 'compact' | 'expanded' + featuredLinks: Record<string, Array<FeaturedLink>> + renderedPage: string + currentLearningTrack?: LearningTrack + currentLayout: string +} + +export const JourneyContext = createContext<JourneyContextT | null>(null) + +export const useJourneyContext = (): JourneyContextT => { + const context = useContext(JourneyContext) + + if (!context) { + throw new Error('"useJourneyContext" may only be used inside "JourneyContext.Provider"') + } + + return context +} + +export const getJourneyContextFromRequest = async (req: any): Promise<JourneyContextT> => { + const page = req.context.page + + return { + title: page.title, + productCallout: page.product || '', + permissions: page.permissions || '', + intro: page.intro, + tocItems: (req.context.genericTocFlat || req.context.genericTocNested || []).map( + mapRawTocItemToTocItem, + ), + 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/landings/pages/product.tsx b/src/landings/pages/product.tsx index 44b41bbddf7f..eeed5a9c96d6 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' @@ -46,7 +47,24 @@ import { CategoryLandingContext, CategoryLandingContextT, } from '@/frame/components/context/CategoryLandingContext' -import { useEffect } from 'react' +import { BespokeLanding } from '@/landings/components/bespoke/BespokeLanding' +import { + BespokeContext, + getBespokeContextFromRequest, + BespokeContextT, +} from '@/landings/context/BespokeContext' +import { DiscoveryLanding } from '@/landings/components/discovery/DiscoveryLanding' +import { + DiscoveryContext, + DiscoveryContextT, + getDiscoveryContextFromRequest, +} from '@/landings/context/DiscoveryContext' +import { JourneyLanding } from '@/landings/components/journey/JourneyLanding' +import { + getJourneyContextFromRequest, + JourneyContext, + JourneyContextT, +} from '@/landings/context/JourneyContext' function initiateArticleScripts() { copyCode() @@ -61,6 +79,9 @@ type Props = { tocLandingContext?: TocLandingContextT articleContext?: ArticleContextT categoryLandingContext?: CategoryLandingContextT + bespokeContext?: BespokeContextT + discoveryContext?: DiscoveryContextT + journeyContext?: JourneyContextT } const GlobalPage = ({ mainContext, @@ -69,6 +90,9 @@ const GlobalPage = ({ tocLandingContext, articleContext, categoryLandingContext, + bespokeContext, + journeyContext, + discoveryContext, }: Props) => { const router = useRouter() @@ -82,7 +106,25 @@ const GlobalPage = ({ }, [router.events]) let content - if (productLandingContext) { + if (bespokeContext) { + content = ( + <BespokeContext.Provider value={bespokeContext}> + <BespokeLanding /> + </BespokeContext.Provider> + ) + } else if (discoveryContext) { + content = ( + <DiscoveryContext.Provider value={discoveryContext}> + <DiscoveryLanding /> + </DiscoveryContext.Provider> + ) + } else if (journeyContext) { + content = ( + <JourneyContext.Provider value={journeyContext}> + <JourneyLanding /> + </JourneyContext.Provider> + ) + } else if (productLandingContext) { content = ( <ProductLandingContext.Provider value={productLandingContext}> <ProductLanding /> @@ -140,7 +182,20 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const additionalUINamespaces: string[] = [] // This looks a little funky, but it's so we only send one context's data to the client - if (currentLayoutName === 'product-landing') { + // TODO: TEMP: This is a temporary solution to turn off/on new landing pages while we develop them + if (currentLayoutName === 'bespoke-landing' || req.query?.feature === 'bespoke-landing') { + props.bespokeContext = await getBespokeContextFromRequest(req) + additionalUINamespaces.push('bespoke_landing') + } else if (currentLayoutName === 'journey-landing' || req.query?.feature === 'journey-landing') { + props.journeyContext = await getJourneyContextFromRequest(req) + additionalUINamespaces.push('journey_landing') + } else if ( + currentLayoutName === 'discovery-landing' || + req?.query?.feature === 'discovery-landing' + ) { + props.discoveryContext = await getDiscoveryContextFromRequest(req) + additionalUINamespaces.push('discovery_landing') + } else if (currentLayoutName === 'product-landing') { props.productLandingContext = await getProductLandingContextFromRequest(req) additionalUINamespaces.push('product_landing') } else if (currentLayoutName === 'product-guides') { diff --git a/src/search/components/input/SearchOverlay.tsx b/src/search/components/input/SearchOverlay.tsx index 177a6f7a8205..f8977830d726 100644 --- a/src/search/components/input/SearchOverlay.tsx +++ b/src/search/components/input/SearchOverlay.tsx @@ -5,13 +5,11 @@ import { ActionList, Box, IconButton, - Link, Overlay, Spinner, Stack, Text, TextInput, - Token, } from '@primer/react' import { SearchIcon, @@ -31,18 +29,18 @@ import { executeGeneralSearch, GENERAL_SEARCH_CONTEXT, } from '../helpers/execute-search-actions' - -import styles from './SearchOverlay.module.scss' import { Banner } from '@primer/react/drafts' import { useCombinedSearchResults } from '@/search/components/hooks/useAISearchAutocomplete' import { AskAIResults } from './AskAIResults' import { sendEvent, uuidv4 } from '@/events/components/events' -import { getIsStaff } from '@/events/components/dotcom-cookies' import { EventType } from '@/events/types' import { ASK_AI_EVENT_GROUP, SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups' +import { useSharedUIContext } from '@/frame/components/context/SharedUIContext' + import type { AIReference } from '../types' import type { AutocompleteSearchHit, GeneralSearchHit } from '@/search/types' -import { useSharedUIContext } from '@/frame/components/context/SharedUIContext' + +import styles from './SearchOverlay.module.scss' type Props = { searchOverlayOpen: boolean @@ -783,36 +781,6 @@ export function SearchOverlay({ }} /> <div key="description" className={styles.footer}> - <Box - sx={{ - display: 'flex', - alignContent: 'start', - alignItems: 'start', - }} - > - <Token - as="span" - text="Beta" - className={styles.betaToken} - sx={{ - backgroundColor: 'var(--overlay-bg-color)', - }} - /> - <Link - onClick={async () => { - if (await getIsStaff()) { - // Hubbers users use an internal discussion for feedback - window.open('https://github.com/github/docs-team/discussions/5172', '_blank') - } else { - // public discussion for feedback - window.open('https://github.com/orgs/community/discussions/164214', '_blank') - } - }} - as="button" - > - <u>{t('search.overlay.give_feedback')}</u> - </Link> - </Box> <Text as="p" sx={{