feat(svelte): Migrate dotcom community search pages to Svelte (#64388)

What: This PR does the bare minimum to migrate the current community
search pages to Svelte. A better strategy for managing them is needed in
the medium/long term.

How: The community pages live at the root (e.g. `/kubernetes`) which
complicates things, but I'll get to that later. The page is implemented
as a single parameterized route. A parameter matcher is used to validate
the community name. Because these pages should only be accessible on
dotcom the matcher also validates whether or not we are on dotcom (if
not, the path will be matched against a different route).

The page config is stored in a separate module so that it's no included
in every page and so that it can be used in the integration test.

The loader and page implementation themselves are straightforward. I
made a couple of changes in other modules to make implementation easier:

- Extracted the parameter type of the `marked` function so that it can
be used as prop type.
- Added an `inline` option to `marked` that allows formatting markdown
as 'inline', i.e. without `p` wrapper.
- Added a `wrap` prop to `SyntaxHighlightedQuery.svelte` to configure
line wrapping of syntax highlighted search queries (instead of having to
overwrite styles with `:global`).
- Extended the route code generator to be able to handle single
parameter segments and the `communitySearchContext` matcher.

Because the community routes should only be available on dotcom I added
a new tag to the code generator that allows it include routes only for
dotcom.
Once we change how all this works and have community search pages live
under a different path we can simplify this again.

Result:

| React | Svelte |
|--------|--------|
|
![2024-08-09_16-05](https://github.com/user-attachments/assets/a64c4e0c-a9dd-4248-9466-05b348559408)
|
![2024-08-09_16-04](https://github.com/user-attachments/assets/d8ad424b-d04a-4590-b198-a6b2f4a76816)
|


## Test plan

- New integration tests.
- Verified that `/kubernetes` shows a 'repo not found error' when
running against S2.
- Verified that `/kubernetes` shows the community page when running
against dotcom.
- Verified that `window.context.svelteKit.enabledRoutes` contains the
community page route in enterprise mode but not in dotcom mode.
This commit is contained in:
Felix Kling 2024-08-09 22:52:47 +02:00 committed by GitHub
parent f1060eccac
commit 8296e9804f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 769 additions and 30 deletions

View File

@ -41,6 +41,42 @@ export const highlightCodeSafe = (code: string, language?: string): string => {
}
}
export interface RenderMarkdownOptions {
/**
* Whether to render markdown inline, without paragraph tags
*/
inline?: boolean
/**
* Whether to render line breaks as HTML `<br>`s
*/
breaks?: boolean
/**
* Whether to disable autolinks. Explicit links using `[text](url)` are still allowed.
*/
disableAutolinks?: boolean
/**
* A custom renderer to use
*/
renderer?: marked.Renderer
/**
* A prefix to add to all header IDs
*/
headerPrefix?: string
/**
* Strip off any HTML and return a plain text string, useful for previews
*/
plainText?: boolean
/**
* DOMPurify configuration to use
*/
dompurifyConfig?: DOMPurifyConfig & { RETURN_DOM_FRAGMENT?: false; RETURN_DOM?: false }
/**
* Add target="_blank" and rel="noopener" to all <a> links that have a href value.
* This affects all markdown-formatted links and all inline HTML links.
*/
addTargetBlankToAllLinks?: boolean
}
/**
* Renders the given markdown to HTML, highlighting code and sanitizing dangerous HTML.
* Can throw an exception on parse errors.
@ -55,18 +91,7 @@ export const highlightCodeSafe = (code: string, language?: string): string => {
* @param options.addTargetBlankToAllLinks Add target="_blank" and rel="noopener" to all <a> links
* that have a href value. This affects all markdown-formatted links and all inline HTML links.
*/
export const renderMarkdown = (
markdown: string,
options: {
breaks?: boolean
disableAutolinks?: boolean
renderer?: marked.Renderer
headerPrefix?: string
plainText?: boolean
dompurifyConfig?: DOMPurifyConfig & { RETURN_DOM_FRAGMENT?: false; RETURN_DOM?: false }
addTargetBlankToAllLinks?: boolean
} = {}
): string => {
export const renderMarkdown = (markdown: string, options: RenderMarkdownOptions = {}): string => {
const tokenizer = new marked.Tokenizer()
if (options.disableAutolinks) {
// Why the odd double-casting below?
@ -76,7 +101,7 @@ export const renderMarkdown = (
tokenizer.url = () => undefined as unknown as marked.Tokens.Link
}
const rendered = marked(markdown, {
const rendered = (options.inline ? marked.parseInline : marked)(markdown, {
gfm: true,
breaks: options.breaks,
highlight: (code, language) => highlightCodeSafe(code, language),

View File

@ -199,7 +199,7 @@ playwright_test_bin.playwright_test(
],
data = glob(
[
"src/**/*.spec.ts",
"src/**/*.ts",
"src/testing/*.ts",
],
) + [

View File

@ -22,7 +22,7 @@
window.context,
// Dev specific overwrites
{
sentryDNS: undefined,
sentryDSN: undefined,
},
// Playwright specific overwrites
window.playwrightContext

View File

@ -6,7 +6,7 @@ export { createAggregateError, asError } from '@sourcegraph/common/src/errors/er
export { memoizeObservable, resetAllMemoizationCaches } from '@sourcegraph/common/src/util/rxjs/memoizeObservable'
export { encodeURIPathComponent } from '@sourcegraph/common/src/util/url'
export { pluralize, numberWithCommas } from '@sourcegraph/common/src/util/strings'
export { renderMarkdown } from '@sourcegraph/common/src/util/markdown/markdown'
export { renderMarkdown, type RenderMarkdownOptions } from '@sourcegraph/common/src/util/markdown/markdown'
export { highlightNodeMultiline, highlightNode } from '@sourcegraph/common/src/util/highlightNode'
export { logger } from '@sourcegraph/common/src/util/logger'
export { isSafari } from '@sourcegraph/common/src/util/browserDetection'

View File

@ -61,6 +61,11 @@ export const svelteKitRoutes: SvelteKitRoute[] = [
pattern: new RegExp('^/(?:(?:(?:[^@/-]|(?:[^/@]{2,}))/)*(?:[^@/-]|(?:[^/@]{2,})))(?:@(?:(?:(?:[^@/-]|(?:[^/@]{2,}))/)*(?:[^@/-]|(?:[^/@]{2,}))))?/-/tags/?$'),
isRepoRoot: false,
},
{
id: '/[community=communitySearchContext]',
pattern: new RegExp('^/(backstage|chakraui|cncf|julia|kubernetes|o3de|stackstorm|stanford|temporal)/?$'),
isRepoRoot: false,
},
{
id: '/search',
pattern: new RegExp('^/search/?$'),

View File

@ -1,16 +1,22 @@
<script lang="ts">
import { decorateQuery } from '$lib/branded'
import type { SearchPatternType } from '@sourcegraph/web/src/graphql-operations'
import { decorateQuery } from '$lib/branded'
import EmphasizedLabel from './EmphasizedLabel.svelte'
export let query: string
export let patternType: SearchPatternType | undefined = undefined
export let matches: Set<number> | null = null
/**
* If true the query will be wrapped between tokens as necessary
*/
export let wrap: boolean = false
$: decorations = decorateQuery(query, patternType)
</script>
<code class="search-query-link">
<code class="search-query-link" class:wrap>
{#if decorations}
{#each decorations as { key, className, value, token } (key)}
<span class={className}>
@ -27,5 +33,10 @@
<style lang="scss">
code {
font-size: inherit;
&.wrap {
white-space: initial;
line-height: initial;
}
}
</style>

View File

@ -0,0 +1,13 @@
export const communities = [
'backstage',
'chakraui',
'cncf',
'julia',
'kubernetes',
'o3de',
'stackstorm',
'stanford',
'temporal',
] as const
export type Community = typeof communities[number]

View File

@ -1,13 +1,17 @@
<script lang="ts">
import { renderMarkdown } from '$lib/common'
import { renderMarkdown, type RenderMarkdownOptions } from '$lib/common'
import styles from './Markdown.module.scss'
export let content: string
export let options: RenderMarkdownOptions | undefined = undefined
export let inline = false
$: finalOptions = options ? { ...options, inline } : { inline }
</script>
{#if content}
<div class={styles.markdown}>
{@html renderMarkdown(content)}
</div>
<svelte:element this={inline ? 'span' : 'div'} class={styles.markdown}>
{@html renderMarkdown(content, finalOptions)}
</svelte:element >
{/if}

View File

@ -0,0 +1,10 @@
import type { ParamMatcher } from '@sveltejs/kit'
import { browser } from '$app/environment'
import { communities } from '$lib/search/communityPages'
const names: readonly string[] = communities
export const match: ParamMatcher = param => {
return browser && window.context.sourcegraphDotComMode && names.includes(param.toLowerCase())
}

View File

@ -0,0 +1,274 @@
<script lang="ts" context="module">
import type { Community } from '$lib/search/communityPages'
// Unique number identifier for telemetry
const specTypes: Record<string, number> = {
backstage: 0,
chakraui: 1,
cncf: 2,
temporal: 3,
o3de: 4,
stackstorm: 5,
kubernetes: 6,
stanford: 7,
julia: 8,
} satisfies Record<Community, number>
</script>
<script lang="ts">
// @sg EnableRollout Dotcom
import { onMount } from 'svelte'
import { buildSearchURLQuery } from '@sourcegraph/shared/src/util/url'
import LoadingSpinner from '$lib/LoadingSpinner.svelte'
import CodeHostIcon from '$lib/search/CodeHostIcon.svelte'
import SearchInput from '$lib/search/input/SearchInput.svelte'
import { queryStateStore } from '$lib/search/state'
import SyntaxHighlightedQuery from '$lib/search/SyntaxHighlightedQuery.svelte'
import { displayRepoName } from '$lib/shared'
import { settings } from '$lib/stores'
import { TELEMETRY_RECORDER } from '$lib/telemetry'
import { Alert, Button, Markdown } from '$lib/wildcard'
import type { PageData } from './$types'
export let data: PageData
$: ({ title, description, homepageIcon, spec, lowProfile, examples } = data)
$: context = `context:${spec}`
$: queryState = queryStateStore({ query: context + ' ' }, $settings)
onMount(() => {
TELEMETRY_RECORDER.recordEvent('communitySearchContext', 'view', {
metadata: { spec: specTypes[data.community] },
})
})
function handleSearchExampleClick() {
TELEMETRY_RECORDER.recordEvent('communitySearchContext.suggestion', 'click')
}
function handleRepoLinkClick() {
TELEMETRY_RECORDER.recordEvent('communitySearchContext.repoLink', 'click')
}
function handleExternalRepoLinkClick() {
TELEMETRY_RECORDER.recordEvent('communitySearchContext.repoLink.external', 'click')
}
</script>
<svelte:head>
<title>{title} - Sourcegraph</title>
</svelte:head>
<section>
<hgroup>
<h2><img src={homepageIcon} alt="" aria-hidden /><span>{title}</span></h2>
<!-- We provide our own p element so that we can target it with a CSS selector without :global -->
<p>
<Markdown content={description} inline />
</p>
</hgroup>
<SearchInput {queryState} autoFocus />
{#if !lowProfile}
<div class="main">
{#if examples.length > 0}
<div class="column examples">
<h3>Search examples</h3>
<ul data-testid="page.community.examples">
{#each examples as example}
{@const query = `${context} ${example.query}`}
<li>
<h4><Markdown content={example.title} inline /></h4>
<SyntaxHighlightedQuery wrap {query} patternType={example.patternType} />
<Button variant="secondary">
<a
slot="custom"
let:buttonClass
class={buttonClass}
href="/search?{buildSearchURLQuery(query, example.patternType, false)}"
on:click={handleSearchExampleClick}>Search</a
>
</Button>
{#if example.description}
<div class="description">
<Markdown content={example.description} />
</div>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<aside class="column repositories">
<h3>Repositories</h3>
<div class="content">
<p>
Using the context <SyntaxHighlightedQuery query={context} /> in a query will search these repositories:
</p>
{#await data.repositories}
<LoadingSpinner center />
{:then repositories}
<ul class="repositories" data-testid="page.community.repositories">
{#each repositories as { repository: repo } (repo.name)}
<li>
{#if repo.externalURLs.length > 0}
<a
href={repo.externalURLs[0].url}
target="_blank"
rel="noopener noreferrer"
on:click={handleExternalRepoLinkClick}
>
<CodeHostIcon repository={repo.name} />
</a>
{/if}
<a href="/{repo.name}" on:click={handleRepoLinkClick}>
{displayRepoName(repo.name)}
</a>
</li>
{/each}
</ul>
{:catch error}
<Alert variant="danger">{error.message}</Alert>
{/await}
</div>
</aside>
</div>
{/if}
</section>
<style lang="scss">
section {
width: 100%;
max-width: var(--viewport-lg);
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
align-items: center;
}
h2 {
img {
width: 3rem;
height: 3rem;
margin: 0.5rem;
}
> * {
vertical-align: middle;
}
}
hgroup {
margin-top: 3rem;
@media (--sm-breakpoint-down) {
margin-top: 1rem;
}
> * {
text-align: center;
}
}
.main {
margin-top: 2rem;
display: flex;
gap: 1rem;
align-items: flex-start;
padding: 1rem;
box-sizing: border-box;
max-width: 100%;
--card-border-radius: var(--border-radius);
@media (--sm-breakpoint-down) {
flex-direction: column;
align-items: stretch;
}
@media (--xs-breakpoint-down) {
--card-border-radius: 0;
padding: 0;
h3 {
margin-left: 1rem;
}
}
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
.examples {
flex: 1;
min-width: 0;
ul {
display: grid;
grid-template-columns: 1fr auto;
grid-row-gap: 1rem;
li {
display: grid;
grid-column: span 2;
grid-template-columns: subgrid;
grid-template-rows: auto auto auto;
align-items: center;
row-gap: 0.5rem;
border: 1px solid var(--border-color);
padding: 1rem;
border-radius: var(--card-border-radius);
background-color: var(--color-bg-1);
> h4 {
grid-column: span 2;
}
.description {
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
grid-column: span 2;
}
}
}
}
.repositories {
flex: 1;
// Forces column to grow so that examples and repositories are
// the same width
min-width: 0;
.content {
border: 1px solid var(--border-color);
padding: 1rem;
border-radius: var(--card-border-radius);
background-color: var(--color-bg-2);
}
ul {
columns: 2;
@media (--md-breakpoint-down) {
columns: 1;
}
li {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.125rem 0;
}
}
}
</style>

View File

@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit'
import { getGraphQLClient, mapOrThrow } from '$lib/graphql'
import type { PageLoad } from './$types'
import { communityPageConfigs } from './config'
import { CommunitySearchPage_SearchContext } from './page.gql'
export const load: PageLoad = ({ params }) => {
const community = params.community.toLowerCase()
const config = communityPageConfigs[community]
if (!config) {
// This should never happen because the router won't even load this page if the
// parameter is not a known community name.
error(404, `Community search context not found: ${params.community}`)
}
return {
...config,
community,
repositories: getGraphQLClient()
.query(CommunitySearchPage_SearchContext, {
spec: config.spec,
})
.then(mapOrThrow(({ data }) => data?.searchContextBySpec?.repositories ?? [])),
}
}

View File

@ -0,0 +1,250 @@
import { SearchPatternType } from '$lib/graphql-types'
import type { Community } from '$lib/search/communityPages'
// This needs to be exported so that TS type inference can work in SvelteKit generated files.
export interface ExampleQuery {
title: string
description?: string
query: string
patternType: SearchPatternType
}
// This needs to be exported so that TS type inference can work in SvelteKit generated files.
export interface CommunitySearchContextMetadata {
/**
* The title of the community search context. This is displayed on the search homepage, and is typically prose. E.g. Refactor python 2 to 3.
*/
title: string
/**
* The name of the community search context, must match the community search context name as configured in settings. E.g. python2-to-3.
*/
spec: string
/**
* A list of example queries using the community search context. Don't include the `context:name` portion of the query. It will be automatically added.
*/
examples: ExampleQuery[]
/**
* A description of the community search context to be displayed on the page.
*/
description: string
/**
* Base64 data uri to an icon.
*/
homepageIcon: string
/**
* A description when displayed on the search homepage.
*/
homepageDescription: string
/**
* Whether to display this in a minimal community search context page, without examples/repositories/descriptions below the search bar.
*/
lowProfile?: boolean
}
export const communityPageConfigs: Record<string, CommunitySearchContextMetadata> = {
backstage: {
title: 'Backstage',
spec: 'backstage',
description: 'Explore over 25 different Backstage related repositories. Search with examples below.',
examples: [
{
title: 'Browse diffs for recent code changes',
query: 'type:diff after:"1 week ago"',
patternType: SearchPatternType.standard,
},
],
homepageDescription: 'Search within the Backstage community.',
homepageIcon: 'https://raw.githubusercontent.com/sourcegraph-community/backstage-context/main/backstage.svg',
},
julia: {
title: 'Julia',
spec: 'julia',
description: 'Explore over 20 different Julia repositories. Search with examples below.',
examples: [
{
title: "List all TODO's in Julia code",
query: 'lang:Julia TODO case:yes',
patternType: SearchPatternType.standard,
},
{
title: 'Browse diffs for recent code changes',
query: 'type:diff after:"1 week ago"',
patternType: SearchPatternType.standard,
},
],
homepageDescription: 'Search within the Julia community.',
homepageIcon: 'https://raw.githubusercontent.com/JuliaLang/julia/master/doc/src/assets/logo.svg',
},
kubernetes: {
title: 'Kubernetes',
spec: 'kubernetes',
description: 'Explore Kubernetes repositories on GitHub. Search with examples below.',
examples: [
{
title: 'Use a ReplicationController configuration to ensure specified number of pod replicas are running at any one time',
query: 'file:pod.yaml content:"kind: ReplicationController"',
patternType: SearchPatternType.standard,
},
{
title: 'Look for outdated `apiVersions` of admission webhooks',
description: `This apiVersion has been deprecated in favor of "admissionregistration.k8s.io/v1".
You can read more about this at https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/`,
query: 'content:"apiVersion: admissionregistration.k8s.io/v1beta1"',
patternType: SearchPatternType.standard,
},
{
title: 'Find Prometheus usage in YAML files',
query: 'lang:yaml prom/prometheus',
patternType: SearchPatternType.standard,
},
{
title: 'Search for examples of the sidecar pattern in Go',
query: 'lang:go sidecar',
patternType: SearchPatternType.standard,
},
{
title: 'Browse diffs for recent code changes',
query: 'type:diff after:"1 week ago"',
patternType: SearchPatternType.standard,
},
],
homepageDescription: 'Search within the Kubernetes community.',
homepageIcon: 'https://code.benco.io/icon-collection/logos/kubernetes.svg',
},
stackstorm: {
title: 'StackStorm',
spec: 'stackstorm',
description: '',
examples: [
{
title: 'Passive sensor examples',
patternType: SearchPatternType.standard,
query: 'from st2reactor.sensor.base import Sensor',
},
{
title: 'Polling sensor examples',
patternType: SearchPatternType.standard,
query: 'from st2reactor.sensor.base import PollingSensor',
},
{
title: 'Trigger examples in rules',
patternType: SearchPatternType.standard,
query: 'repo:Exchange trigger: file:.yaml$',
},
{
title: 'Actions that use the Orquesta runner',
patternType: SearchPatternType.regexp,
query: 'repo:Exchange runner_type:\\s*"orquesta"',
},
],
homepageDescription: 'Search within the StackStorm and StackStorm Exchange community.',
homepageIcon: 'https://avatars.githubusercontent.com/u/4969009?s=200&v=4',
},
stanford: {
title: 'Stanford University',
spec: 'stanford',
description: 'Explore open-source code from Stanford students, faculty, research groups, and clubs.',
examples: [
{
title: 'Find all mentions of "machine learning" in Stanford projects.',
patternType: SearchPatternType.standard,
query: 'machine learning',
},
{
title: 'Explore the code of specific research groups like Hazy Research, a group that investigates machine learning models and automated training set creation.',
patternType: SearchPatternType.standard,
query: 'repo:/HazyResearch/',
},
{
title: 'Explore the code of a specific user or organization such as Stanford University School of Medicine.',
patternType: SearchPatternType.standard,
query: 'repo:/susom/',
},
{
title: 'Search for repositories related to introductory programming concepts.',
patternType: SearchPatternType.standard,
query: 'repo:recursion',
},
{
title: 'Explore the README files of thousands of projects.',
patternType: SearchPatternType.standard,
query: 'file:README.txt',
},
{
title: 'Find old-style string formatted print statements.',
patternType: SearchPatternType.structural,
query: 'lang:python print(:[args] % :[v])',
},
],
homepageDescription: 'Explore Stanford open-source code.',
homepageIcon:
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/Icons8_flat_graduation_cap.svg/120px-Icons8_flat_graduation_cap.svg.png',
},
temporal: {
title: 'Temporal',
spec: 'temporalio',
description: '',
examples: [
{
title: 'All test functions',
patternType: SearchPatternType.standard,
query: 'type:symbol Test',
},
{
title: 'Search for a specifc function or class',
patternType: SearchPatternType.standard,
query: 'type:symbol SimpleSslContextBuilder',
},
],
homepageDescription: 'Search within the Temporal organization.',
homepageIcon: 'https://avatars.githubusercontent.com/u/56493103?s=200&v=4',
},
chakraui: {
title: 'CHAKRA UI',
spec: 'chakraui',
description: '',
examples: [
{
title: 'Search for Chakra UI packages',
patternType: SearchPatternType.standard,
query: 'file:package.json',
},
{
title: 'Browse diffs for recent code changes',
patternType: SearchPatternType.standard,
query: 'type:diff after:"1 week ago"',
},
],
homepageDescription: 'Search within the Chakra UI organization.',
homepageIcon: 'https://raw.githubusercontent.com/chakra-ui/chakra-ui/main/logo/logomark-colored.svg',
},
cncf: {
title: 'Cloud Native Computing Foundation (CNCF)',
spec: 'cncf',
description: 'Search the [CNCF projects](https://landscape.cncf.io/project=hosted)',
examples: [],
homepageDescription: 'Search CNCF projects.',
homepageIcon: 'https://raw.githubusercontent.com/cncf/artwork/master/other/cncf/icon/color/cncf-icon-color.png',
lowProfile: true,
},
o3de: {
title: 'O3DE',
spec: 'o3de',
description: '',
examples: [
{
title: 'Search for O3DE gems',
patternType: SearchPatternType.standard,
query: 'file:gem.json',
},
{
title: 'Browse diffs for recent code changes',
patternType: SearchPatternType.standard,
query: 'type:diff after:"1 week ago"',
},
],
homepageDescription: 'Search within the O3DE organization.',
homepageIcon:
'https://raw.githubusercontent.com/o3de/artwork/19b89e72e15824f20204a8977a007f53d5fcd5b8/o3de/03_O3DE%20Application%20Icon/SVG/O3DE-Circle-Icon.svg',
},
} satisfies Record<Community, CommunitySearchContextMetadata>

View File

@ -0,0 +1,12 @@
query CommunitySearchPage_SearchContext($spec: String!) {
searchContextBySpec(spec: $spec) {
repositories {
repository {
name
externalURLs {
url
}
}
}
}
}

View File

@ -0,0 +1,73 @@
import { test, expect } from '$testing/integration'
import { communityPageConfigs } from './config'
test.describe('community', async () => {
test.beforeEach(async ({ sg }) => {
await sg.dotcomMode()
sg.mockOperations({
CommunitySearchPage_SearchContext() {
return {
searchContextBySpec: {
repositories: [
{ repository: { name: 'repo1' } },
{ repository: { name: 'repo2' } },
{ repository: { name: 'repo3' } },
],
},
}
},
})
})
for (const [name, config] of Object.entries(communityPageConfigs)) {
test(name, async ({ page }) => {
await page.goto(`/${name}`)
await test.step('correct heading is shown', async () => {
await expect(page.getByRole('heading', { name: config.title, level: 2 })).toBeVisible()
})
await test.step('search input is prefilled with search context', async () => {
await expect(page.getByRole('textbox')).toHaveText(new RegExp(`^context:${config.spec} `))
})
// We are not testing description because it may contain markdown which
// is more difficult to test for
if (config.lowProfile) {
await test.step('low profile mode', async () => {
await expect(page.getByTestId('page.community.examples')).not.toBeVisible()
await expect(page.getByTestId('page.community.repositories')).not.toBeVisible()
})
} else {
if (config.examples.length > 0) {
await test.step('search examples are listed', async () => {
await expect(page.getByTestId('page.community.examples').getByRole('listitem')).toHaveCount(
config.examples.length
)
})
} else {
await expect(page.getByRole('heading', { name: 'Search examples' })).not.toBeVisible()
}
await test.step('repositories are listed', async () => {
// Repositories are rendered
await expect(page.getByTestId('page.community.repositories').getByRole('listitem')).toHaveCount(3)
})
}
})
}
test('show repository resolution error', async ({ page, sg }) => {
await page.goto('/backstage')
sg.mockOperations({
CommunitySearchPage_SearchContext() {
throw new Error('Test error')
},
})
await expect(page.getByText('Test error')).toBeVisible()
})
})

View File

@ -107,7 +107,7 @@ export class Sourcegraph {
})
// Intercept any asset calls and replace them with static files
await this.page.route(/.assets|_app/, route => {
await this.page.route(/\.assets|_app/, route => {
const assetPath = new URL(route.request().url()).pathname.replace('/.assets/', '')
const asset = joinDistinct(ASSETS_DIR, assetPath)
const contentType = mime.contentType(path.basename(asset)) || undefined

View File

@ -61,6 +61,11 @@ export const svelteKitRoutes: SvelteKitRoute[] = [
pattern: new RegExp('^/(?:(?:(?:[^@/-]|(?:[^/@]{2,}))/)*(?:[^@/-]|(?:[^/@]{2,})))(?:@(?:(?:(?:[^@/-]|(?:[^/@]{2,}))/)*(?:[^@/-]|(?:[^/@]{2,}))))?/-/tags/?$'),
isRepoRoot: false,
},
{
id: '/[community=communitySearchContext]',
pattern: new RegExp('^/(backstage|chakraui|cncf|julia|kubernetes|o3de|stackstorm|stanford|temporal)/?$'),
isRepoRoot: false,
},
{
id: '/search',
pattern: new RegExp('^/search/?$'),

View File

@ -11,6 +11,7 @@ go_library(
visibility = ["//cmd/frontend:__subpackages__"],
deps = [
"//cmd/frontend/internal/app/ui/sveltekit/tags",
"//internal/dotcom",
"//internal/env",
"//internal/featureflag",
"//lib/errors",

View File

@ -183,6 +183,7 @@ var (
tagPattern = regexp.MustCompile(`^\s*//\s+@sg\s+`)
groupPattern = regexp.MustCompile(`^\([^)]+\)$`)
restParamPattern = regexp.MustCompile(`^\[\.\.\.(\w+)(?:=(\w+))?\]$`)
paramPattern = regexp.MustCompile(`^\[(\w+)(?:=(\w+))?\]$`)
optionalParamPattern = regexp.MustCompile(`^\[\[(\w+)(?:=(\w+))?\]\]$`)
)
@ -226,7 +227,8 @@ func getRouteInfo(path string) (*routeInfo, error) {
// Map SvelteKit specific parameter matchers to regular expressions. This is a "best effort" approach
// because parameter matchers in SvelteKit are functions that can perform arbitrary logic.
var paramMatchers = map[string]string{
"reporev": "/" + routevar.RepoPatternNonCapturing + `(?:@` + routevar.RevPatternNonCapturing + `)?`,
"reporev": "/" + routevar.RepoPatternNonCapturing + `(?:@` + routevar.RevPatternNonCapturing + `)?`,
"communitySearchContext": "/(backstage|chakraui|cncf|julia|kubernetes|o3de|stackstorm|stanford|temporal)",
}
// This code follows the regex generation logic from
@ -257,6 +259,19 @@ func patternForRouteId(routeId string) (string, error) {
continue
}
// [param]
if paramPattern.MatchString(segment) {
matches := paramPattern.FindStringSubmatch(segment)
if len(matches) == 3 {
if matcher, ok := paramMatchers[matches[2]]; ok {
b.WriteString(matcher)
continue
}
}
b.WriteString(`(?:/[^/]+)`)
continue
}
// [[optional]]
if optionalParamPattern.MatchString(segment) {
b.WriteString(`(?:/[^/]+)?`)

View File

@ -68,6 +68,11 @@ var svelteKitRoutes = []svelteKitRoute{
Pattern: regexp.MustCompile("^/(?:(?:(?:[^@/-]|(?:[^/@]{2,}))/)*(?:[^@/-]|(?:[^/@]{2,})))(?:@(?:(?:(?:[^@/-]|(?:[^/@]{2,}))/)*(?:[^@/-]|(?:[^/@]{2,}))))?/-/tags/?$"),
Tag: tags.EnableOptIn | tags.EnableRollout,
},
{
Id: "/[community=communitySearchContext]",
Pattern: regexp.MustCompile("^/(backstage|chakraui|cncf|julia|kubernetes|o3de|stackstorm|stanford|temporal)/?$"),
Tag: tags.EnableOptIn | tags.EnableRollout | tags.Dotcom,
},
{
Id: "/search",
Pattern: regexp.MustCompile("^/search/?$"),

View File

@ -12,6 +12,7 @@ import (
"github.com/gorilla/mux"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app/ui/sveltekit/tags"
"github.com/sourcegraph/sourcegraph/internal/dotcom"
"github.com/sourcegraph/sourcegraph/internal/env"
"github.com/sourcegraph/sourcegraph/internal/featureflag"
"github.com/sourcegraph/sourcegraph/lib/errors"
@ -55,6 +56,7 @@ func (r *svelteKitRoute) matches(url *url.URL) bool {
// RegisterSvelteKit registers a middleware that determines which routes are enabled for SvelteKit.
// It also extends the request context with information that is sent to the client apps via JSContext.
func RegisterSvelteKit(r *mux.Router, repoRootRoute *mux.Route) {
isDotComMode := dotcom.SourcegraphDotComMode()
var knownRoutes []string
r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
@ -84,16 +86,21 @@ func RegisterSvelteKit(r *mux.Router, repoRootRoute *mux.Route) {
enabledRoutes := make([]int, 0, len(svelteKitRoutes))
enabled := false
availabilityMask := tags.EnableAlways
inclusionMask := tags.EnableAlways
if ff.GetBoolOr("web-next", false) {
availabilityMask |= tags.EnableOptIn
inclusionMask |= tags.EnableOptIn
}
if ff.GetBoolOr("web-next-rollout", false) {
availabilityMask |= tags.EnableRollout
inclusionMask |= tags.EnableRollout
}
var exclusionMask tags.Tag
if !isDotComMode {
exclusionMask |= tags.Dotcom
}
for i, skr := range svelteKitRoutes {
if skr.Tag&availabilityMask != 0 {
if skr.Tag&inclusionMask != 0 && skr.Tag&exclusionMask == 0 {
enabledRoutes = append(enabledRoutes, i)
if !enabled {

View File

@ -10,18 +10,20 @@ const (
EnableRollout
// EnableOptin renders the SvelteKit app for this route when the "web-next" feature flag is enabled.
EnableOptIn
// Dotcom marks a route as being only available on Sourcegraph.com
Dotcom
)
// IsTagValid returns true if the tag is a valid tag for a route
// this is used by the code generator to validate the tags
func IsTagValid(tag string) bool {
switch tag {
case "RepoRoot", "EnableAlways", "EnableRollout", "EnableOptIn":
case "RepoRoot", "EnableAlways", "EnableRollout", "EnableOptIn", "Dotcom":
return true
}
return false
}
func AvailableTags() []string {
return []string{"RepoRoot", "EnableAlways", "EnableRollout", "EnableOptIn"}
return []string{"RepoRoot", "EnableAlways", "EnableRollout", "EnableOptIn", "Dotcom"}
}