Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 36 additions & 2 deletions src/frontend/src/components/starlight/Header.astro
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -20,8 +23,13 @@ const tryLabel = t('header.try' as any);
---

<div class="header">
<div class="title-wrapper sl-flex">
<div class:list={['title-wrapper sl-flex', { 'has-environment-badge': environmentBadge }]}>
<SiteTitle />
{
environmentBadge && (
<Badge text={environmentBadge} title={environmentBadge} variant="tip" size="small" />
)
}
</div>
<div class="sl-flex print:hidden" data-tour-target="search" data-tour-step="search">
<Search />
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions src/frontend/tests/unit/header-environment-badge.vitest.test.ts
Original file line number Diff line number Diff line change
@@ -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', '<img src=x onerror=alert(1)>');

const html = await renderHeader();
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
const badge = select('.title-wrapper .sl-badge', unified().use(rehypeParse).parse(html));
expect(badge?.children).toMatchObject([
{ type: 'text', value: '<img src=x onerror=alert(1)>' },
]);
});
});
Loading