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
2 changes: 1 addition & 1 deletion src/frontend/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { locales } from './config/locales.ts';
import { headAttrs } from './config/head.attrs.ts';
import { socialConfig } from './config/socials.config.ts';
import catppuccin from '@catppuccin/starlight';
import lunaria from '@lunariajs/starlight';
import lunaria from './config/lunaria-starlight.mjs';
import mermaid from 'astro-mermaid';
import starlight from '@astrojs/starlight';
import starlightGitHubAlerts from 'starlight-github-alerts';
Expand Down
107 changes: 107 additions & 0 deletions src/frontend/config/lunaria-starlight.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { loadConfig, readConfig, writeConfig } from '@lunariajs/core/config';
import { git } from '@lunariajs/core/git';
import { z } from 'astro/zod';

const LunariaStarlightConfigSchema = z
.object({
configPath: z.string().default('./lunaria.config.json'),
route: z.string().default('/lunaria'),
sync: z.boolean().default(false),
})
.default({});

export default function lunariaStarlight(userConfig) {
const pluginConfig = LunariaStarlightConfigSchema.parse(userConfig);

return {
name: '@lunariajs/starlight',
hooks: {
setup: async ({ addIntegration, config, logger, command }) => {
if (pluginConfig.sync && command === 'build') {
if (config.locales) {
logger.info('Syncing Lunaria configuration with Starlight...');

const lunariaConfig = await readConfig(pluginConfig.configPath);
const starlightFilesEntry = {
location: 'src/content/docs/**/*.{md,mdx}',
pattern: 'src/content/docs/@lang/@path',
type: 'universal',
};

const otherFiles =
lunariaConfig?.files?.filter((file) => file.location !== starlightFilesEntry.location) ??
[];

const locEntries = Object.entries(config.locales);
const locales = locEntries
.filter(([key]) => key !== 'root' && key !== config.defaultLocale)
.map(([key, locale]) => ({
label: locale.label,
lang: key,
}));

const [defaultKey, defaultValue] = locEntries.find(
([key]) => key === config.defaultLocale || key === 'root'
);

const defaultLocale = {
label: defaultValue.label,
lang: defaultValue.lang?.toLowerCase() ?? defaultKey,
};

lunariaConfig.files = [starlightFilesEntry, ...otherFiles];
lunariaConfig.locales = locales;
lunariaConfig.defaultLocale = defaultLocale;

writeConfig(pluginConfig.configPath, lunariaConfig);
logger.info('Sync complete.');
} else {
logger.warn(
'Sync is only supported when your Starlight config includes the locales field.'
);
}
}

await loadConfig(pluginConfig.configPath);
const isShallowRepo = (await git.revparse(['--is-shallow-repository'])) === 'true';

addIntegration({
name: '@lunariajs/starlight',
hooks: {
'astro:config:setup': ({ updateConfig, injectRoute }) => {
updateConfig({
vite: {
plugins: [vitePluginLunariaStarlight(pluginConfig, isShallowRepo)],
},
});

injectRoute({
pattern: pluginConfig.route,
entrypoint: './src/components/lunaria/Dashboard.astro',
});
},
},
});
},
},
};
}

function vitePluginLunariaStarlight(pluginConfig, isShallowRepo) {
const moduleId = 'virtual:lunaria-starlight';
const resolvedModuleId = `\0${moduleId}`;
const moduleContent = `
export const pluginConfig = ${JSON.stringify(pluginConfig)}
export const isShallowRepo = ${JSON.stringify(isShallowRepo)}
`;

return {
name: 'vite-plugin-lunaria-starlight',
load(id) {
return id === resolvedModuleId ? moduleContent : undefined;
},
resolveId(id) {
return id === moduleId ? resolvedModuleId : undefined;
},
};
}
4 changes: 4 additions & 0 deletions src/frontend/config/sidebar/deployment.topics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export const deploymentTopics: StarlightSidebarTopicsUserConfig = {
},
slug: 'deployment/overview',
},
{
label: 'Environments',
slug: 'deployment/environments',
},
{
label: 'Pipelines (aspire do)',
slug: 'deployment/pipelines',
Expand Down
11 changes: 11 additions & 0 deletions src/frontend/src/components/lunaria/Dashboard.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
import { loadConfig } from '@lunariajs/core/config';
import { generateDashboard } from '@lunariajs/core/dashboard';
import { pluginConfig, isShallowRepo } from 'virtual:lunaria-starlight';
import { getStatusFromCache } from './cache.mjs';

const { userConfig, rendererConfig } = await loadConfig(pluginConfig.configPath);
const status = await getStatusFromCache(userConfig, isShallowRepo);
---

<Fragment set:html={generateDashboard(userConfig, rendererConfig, status)} />
111 changes: 111 additions & 0 deletions src/frontend/src/components/lunaria/cache.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { info } from '@lunariajs/core/console';
import { getGitHostingLinks, git } from '@lunariajs/core/git';
import { getLocalizationStatus } from '@lunariajs/core/status';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';

let lastExecutionDate;
let shallowRepoPrepared = false;

const cacheDir = resolve('./node_modules/.cache/lunaria/');
const cachedStatusPath = join(cacheDir, 'status.json');

export async function getStatusFromCache(userConfig, isShallowRepo) {
await prepareShallowRepo(userConfig, isShallowRepo);

const latestCommitDate = (await git.log({ maxCount: 1 })).latest?.date;

if (latestCommitDate === lastExecutionDate) {
return JSON.parse(readFileSync(cachedStatusPath, 'utf-8'));
}

const status = await getLocalizationStatus(userConfig, isShallowRepo);

if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true });
}

writeFileSync(cachedStatusPath, JSON.stringify(status));
lastExecutionDate = latestCommitDate;

return status;
}

async function prepareShallowRepo({ cloneDir, repository }, isShallowRepo) {
if (!isShallowRepo || shallowRepoPrepared) {
return;
}

console.log(
info("Shallow repository detected. A clone of your repository's history will be downloaded and used. ")
);

const target = resolve(cloneDir);

if (existsSync(target)) {
rmSync(target, { recursive: true, force: true });
}

const checkoutGitDir = await git.revparse(['--absolute-git-dir']);
const checkoutHistoryRef = await getCheckoutHistoryRef();
const historyBranchRef = `refs/heads/${repository.branch}`;

await git.clone(getGitHostingLinks(repository).clone(), target, ['--bare']);
await git.cwd({ path: target, root: true });

if (checkoutHistoryRef) {
await git.raw(['fetch', 'origin', `+${checkoutHistoryRef}:${historyBranchRef}`]);
} else {
await git.raw(['fetch', checkoutGitDir, `+HEAD:${historyBranchRef}`]);
}

await git.raw(['symbolic-ref', 'HEAD', historyBranchRef]);
shallowRepoPrepared = true;
}

async function getCheckoutHistoryRef() {
const pointsAtHead = await listOriginRefs(['--points-at', 'HEAD']);
const directRef = pointsAtHead.find(isPullHeadRef) ?? pointsAtHead.find(isNonMainRef);

if (directRef) {
return toRemoteRef(directRef);
}

const mergedRefs = await listOriginRefs(['--merged', 'HEAD']);
const mergedRef = mergedRefs.find(isPullHeadRef) ?? mergedRefs.find(isNonMainRef);

return mergedRef ? toRemoteRef(mergedRef) : null;
}

async function listOriginRefs(extraArgs) {
const output = await git.raw([
'for-each-ref',
'--format=%(refname)',
...extraArgs,
'refs/remotes/origin',
]);

return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}

function isPullHeadRef(ref) {
return ref.startsWith('refs/remotes/origin/pull/') && ref.endsWith('/head');
}

function isNonMainRef(ref) {
return ref !== 'refs/remotes/origin/HEAD' && ref !== 'refs/remotes/origin/main';
}

function toRemoteRef(ref) {
const prefix = 'refs/remotes/origin/';

if (!ref.startsWith(prefix)) {
return null;
}

const suffix = ref.slice(prefix.length);
return suffix.startsWith('pull/') ? `refs/${suffix}` : `refs/heads/${suffix}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,108 @@ await env.withAzdResourceNaming();
```
</Pivot>

## Environments and configuration

The `azd` CLI has its own environment management system that maps to Aspire environments. Understanding how these two systems connect helps you deploy the same application to multiple targets like staging and production.

### azd environments

Each `azd` environment is a named configuration scope stored under `.azure/{name}/` in your project. Create and switch between environments with:

```bash title="Create and select environments"
azd env new staging
azd env new production
azd env select staging
```

Each environment stores its own:

- **Configuration** — `.azure/{name}/config.json` for infrastructure parameters (subscription, location, resource group)
- **Environment variables** — `.azure/{name}/.env` for deployment-specific values
- **Secrets** — referenced through a local vault in `~/.azd/vaults/`

### How azd passes the environment to Aspire

When `azd` invokes your AppHost to generate the deployment manifest, it passes the `DOTNET_ENVIRONMENT` value from the active azd environment's `.env` file to the AppHost process. This means your AppHost's `builder.Environment.EnvironmentName` reflects that value.

To configure this, set `DOTNET_ENVIRONMENT` in your azd environment:

```bash title="Set the Aspire environment for an azd environment"
azd env set DOTNET_ENVIRONMENT staging
azd up
# → AppHost runs with DOTNET_ENVIRONMENT=staging
# → builder.Environment.IsEnvironment("staging") returns true
```

<Aside type="caution">
The azd environment name (from `azd env select staging`) does **not**
automatically set `DOTNET_ENVIRONMENT`. You must explicitly set
`DOTNET_ENVIRONMENT` in each azd environment using `azd env set`. Without
this, the AppHost defaults to `Production` when no environment is specified.
</Aside>

Your AppHost code can branch on this value to vary topology or configuration per environment, as described in the [Environments](/deployment/environments/) guide.

### How parameters work with azd

Parameters in Aspire and `azd` follow different paths:

| Parameter type | How it reaches Azure resources |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Aspire parameters** (`AddParameter`) | Resolved by the AppHost through .NET configuration when generating the manifest. Use `appsettings.{env}.json` or environment variables. |
| **`azd` infrastructure inputs (Bicep parameters)** | Prompted by `azd provision` and stored in `.azure/{name}/config.json`. Used by `azd` directly during Bicep deployment — not passed to the AppHost. |
| **Secrets** | Prompted by `azd provision` and stored in a local vault. Injected into Bicep parameters during provisioning. |

<Aside type="note">
`azd` infrastructure inputs stored in `.azure/{name}/config.json` are consumed
as Bicep parameters during provisioning. They are **not** passed as
environment variables to the AppHost process. If you need a value available in
both your AppHost code and your infrastructure, define it as an Aspire
parameter and provide it through .NET configuration or an environment
variable.
</Aside>

### Deploy to multiple environments

To deploy the same application to staging and production with different Azure resources:

<Steps>

1. **Create environments:**

```bash
azd env new staging
azd env new production
```

2. **Configure the Aspire environment for each:**

```bash
azd env select staging
azd env set DOTNET_ENVIRONMENT staging

azd env select production
azd env set DOTNET_ENVIRONMENT production
```

3. **Deploy to staging** — `azd` prompts for subscription, location, and any required parameters on the first deployment:

```bash
azd env select staging
azd up
```

4. **Deploy to production** — switch environments and deploy. Each environment provisions its own independent set of Azure resources:

```bash
azd env select production
azd up
```

</Steps>

Each environment maintains completely separate state — different resource groups, different secrets, different configuration. The AppHost receives the environment name through `DOTNET_ENVIRONMENT`, so any environment-based branching in your AppHost code (resource topology, parameter defaults) applies automatically.

## Deployment manifest

The `azd` tool relies on the [deployment manifest format](/deployment/azure/manifest-format/) to understand your application topology. The manifest is a JSON document generated from the AppHost that describes resources, bindings, and parameters. It's produced automatically when `azd` invokes the AppHost during deployment.
Expand Down
Loading
Loading