diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8db9298d..dcc1ba817 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,38 @@ We welcome translation contributions! Please see our [Translation guide](https:/ └───📂 public # Static assets served as-is ``` +## Environment badge + +Set `PUBLIC_ENVIRONMENT_BADGE` to display a small text badge beside the header logo +and wordmark, for example `staging`, `test`, or `release/13.6`. The value is public +and rendered as text when Astro builds the site. + +Local development (`pnpm dev`, including the frontend started by Aspire) defaults +to `localhost` when the variable is unset. An explicitly empty or whitespace-only +value hides the badge. Production builds have no badge by default; leave the +variable unset or empty for live deployments. + +To override the badge locally, set the variable in your shell or in +`src/frontend/.env.local` and restart the dev server: + +```dotenv +PUBLIC_ENVIRONMENT_BADGE=release/13.6 +``` + +For the Azure DevOps vnext build, set this environment variable on the **frontend +build step**, not only on the deployed host. Use the full release branch name: + +```yaml +env: + PUBLIC_ENVIRONMENT_BADGE: $[replace(variables['Build.SourceBranch'], 'refs/heads/', '')] +``` + +For `refs/heads/release/13.6`, this renders `release/13.6`. Do not use +`Build.SourceBranchName`, which keeps only the final path segment. Apply this +setting only to the vnext build, not the live build. If vnext builds a different +source branch, set the intended label explicitly instead. Changing the badge on +a deployed static site requires rebuilding its frontend assets. + ## 🆘 Getting help - **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/microsoft/aspire.dev/issues) diff --git a/src/frontend/src/components/starlight/Header.astro b/src/frontend/src/components/starlight/Header.astro index a31880a61..662958af1 100644 --- a/src/frontend/src/components/starlight/Header.astro +++ b/src/frontend/src/components/starlight/Header.astro @@ -1,7 +1,7 @@ --- import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; import Search from 'virtual:starlight/components/Search'; -import { Icon } from '@astrojs/starlight/components'; +import { Badge, Icon } from '@astrojs/starlight/components'; import InstallCliModal from '@components/InstallCliModal.astro'; import LivePip from '@components/LivePip.astro'; import CookiesSvg from '@assets/icons/cookies.svg'; @@ -10,6 +10,9 @@ import SiteNavHelpSvg from '@assets/icons/site-nav-help.svg'; import { addLocaleToPath } from '@utils/locale-routes'; const isSiteTourEnabled = import.meta.env.PUBLIC_ENABLE_SITE_TOUR === 'true'; +const environmentBadge = ( + import.meta.env.PUBLIC_ENVIRONMENT_BADGE ?? (import.meta.env.DEV ? 'localhost' : '') +).trim(); const t = Astro.locals.t; const locale = Astro.locals.starlightRoute?.locale; const docsUrl = addLocaleToPath('/docs/', locale); @@ -20,8 +23,13 @@ const tryLabel = t('header.try' as any); ---
-
+
+ { + environmentBadge && ( + + ) + }
@@ -155,6 +163,20 @@ const tryLabel = t('header.try' as any); padding: 0.25rem; margin: -0.25rem; min-width: 0; + align-items: center; + gap: 0.5rem; + } + + .title-wrapper :global(.sl-badge) { + min-width: 0; + max-width: 12rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .has-environment-badge :global(.site-title) { + flex-shrink: 0; } .right-group, @@ -424,6 +446,18 @@ const tryLabel = t('header.try' as any); flex: 1 1 auto; } + .has-environment-badge { + gap: 0.25rem; + } + + .has-environment-badge :global(.site-title) { + font-size: 1rem; + } + + .has-environment-badge :global(.site-title img) { + height: 1.5rem; + } + .header > [data-tour-target='search'] { margin-inline-start: 0; justify-self: end; diff --git a/src/frontend/tests/unit/header-environment-badge.vitest.test.ts b/src/frontend/tests/unit/header-environment-badge.vitest.test.ts new file mode 100644 index 000000000..a60ecd5d0 --- /dev/null +++ b/src/frontend/tests/unit/header-environment-badge.vitest.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { select } from 'hast-util-select'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; + +import Header from '@components/starlight/Header.astro'; +import { renderComponent } from './astro-test-utils'; + +afterEach(() => vi.unstubAllEnvs()); + +function renderHeader() { + const t = Object.assign((key: string) => key, { + dir: (): 'ltr' => 'ltr', + all: () => ({}), + }); + const starlightRoute = { + siteTitle: 'Aspire', + siteTitleHref: '/', + editUrl: '', + entry: { id: 'test', slug: 'test', filePath: 'test.mdx', data: {} }, + }; + return renderComponent(Header, { locals: { t, starlightRoute } }); +} + +describe('Header environment badge', () => { + test.each([ + { dev: true, value: undefined, expected: 'localhost' }, + { dev: false, value: undefined, expected: undefined }, + { dev: true, value: '', expected: undefined }, + { dev: false, value: '', expected: undefined }, + { dev: true, value: ' ', expected: undefined }, + { dev: false, value: ' ', expected: undefined }, + { dev: true, value: 'test', expected: 'test' }, + { dev: false, value: 'staging', expected: 'staging' }, + { dev: false, value: ' release/13.6 ', expected: 'release/13.6' }, + ])('renders $expected with DEV=$dev and value=$value', async ({ dev, value, expected }) => { + vi.stubEnv('DEV', dev); + vi.stubEnv('PUBLIC_ENVIRONMENT_BADGE', value); + + const html = await renderHeader(); + const tree = unified().use(rehypeParse).parse(html); + const badge = select('.title-wrapper .sl-badge', tree); + if (expected) { + expect(badge?.properties.className).toEqual( + expect.arrayContaining(['sl-badge', 'tip', 'small']) + ); + expect(badge?.properties.className).not.toContain('environment-badge'); + expect(badge?.properties.title).toBe(expected); + expect(badge?.children).toMatchObject([{ type: 'text', value: expected }]); + expect(select('.site-title + .sl-badge', tree)).toBe(badge); + } else { + expect(badge).toBeUndefined(); + } + }); + + test('escapes the configured value rather than rendering HTML', async () => { + vi.stubEnv('PUBLIC_ENVIRONMENT_BADGE', ''); + + const html = await renderHeader(); + expect(html).toContain('<img src=x onerror=alert(1)>'); + const badge = select('.title-wrapper .sl-badge', unified().use(rehypeParse).parse(html)); + expect(badge?.children).toMatchObject([ + { type: 'text', value: '' }, + ]); + }); +});