diff --git a/.changeset/angry-swans-fry.md b/.changeset/angry-swans-fry.md
new file mode 100644
index 000000000000..ce5c513d5fc3
--- /dev/null
+++ b/.changeset/angry-swans-fry.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Redesign Dev Overlay main screen to show more information, such as the coolest integrations, your current Astro version and more.
diff --git a/.changeset/brown-jars-lick.md b/.changeset/brown-jars-lick.md
new file mode 100644
index 000000000000..0d824e445f47
--- /dev/null
+++ b/.changeset/brown-jars-lick.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes an issue where links with the same pathname as the current page, but different search params, were not prefetched.
diff --git a/.changeset/tasty-dryers-bathe.md b/.changeset/tasty-dryers-bathe.md
new file mode 100644
index 000000000000..438597e13b04
--- /dev/null
+++ b/.changeset/tasty-dryers-bathe.md
@@ -0,0 +1,27 @@
+---
+'@astrojs/upgrade': minor
+---
+
+Initial release!
+
+`@astrojs/upgrade` is an automated command-line tool for upgrading Astro and your official Astro integrations together.
+
+Inside of your existing `astro` project, run the following command to install the `latest` version of your integrations.
+
+**With NPM:**
+
+```bash
+npx @astrojs/upgrade
+```
+
+**With Yarn:**
+
+```bash
+yarn dlx @astrojs/upgrade
+```
+
+**With PNPM:**
+
+```bash
+pnpm dlx @astrojs/upgrade
+```
diff --git a/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro b/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
index e61bc1c6c33e..88ce196ae22f 100644
--- a/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
+++ b/packages/astro/e2e/fixtures/prefetch/src/pages/index.astro
@@ -9,6 +9,8 @@
false
+ search param
+
tap
hover
diff --git a/packages/astro/e2e/prefetch.test.js b/packages/astro/e2e/prefetch.test.js
index dc29bde33f07..a19c87680eca 100644
--- a/packages/astro/e2e/prefetch.test.js
+++ b/packages/astro/e2e/prefetch.test.js
@@ -16,7 +16,8 @@ test.describe('Prefetch (default)', () => {
test.beforeEach(async ({ page }) => {
page.on('request', (req) => {
- reqUrls.push(new URL(req.url()).pathname);
+ const urlObj = new URL(req.url());
+ reqUrls.push(urlObj.pathname + urlObj.search);
});
});
@@ -38,6 +39,16 @@ test.describe('Prefetch (default)', () => {
expect(reqUrls).not.toContainEqual('/prefetch-false');
});
+ test('Link with search param should prefetch', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+ expect(reqUrls).not.toContainEqual('/?search-param=true');
+ await Promise.all([
+ page.waitForEvent('request'), // wait prefetch request
+ page.locator('#prefetch-search-param').hover(),
+ ]);
+ expect(reqUrls).toContainEqual('/?search-param=true');
+ });
+
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(reqUrls).not.toContainEqual('/prefetch-tap');
@@ -102,7 +113,8 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => {
test.beforeEach(async ({ page }) => {
page.on('request', (req) => {
- reqUrls.push(new URL(req.url()).pathname);
+ const urlObj = new URL(req.url());
+ reqUrls.push(urlObj.pathname + urlObj.search);
});
});
@@ -129,6 +141,16 @@ test.describe("Prefetch (prefetchAll: true, defaultStrategy: 'tap')", () => {
expect(reqUrls).not.toContainEqual('/prefetch-false');
});
+ test('Link with search param should prefetch', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+ expect(reqUrls).not.toContainEqual('/?search-param=true');
+ await Promise.all([
+ page.waitForEvent('request'), // wait prefetch request
+ page.locator('#prefetch-search-param').hover(),
+ ]);
+ expect(reqUrls).toContainEqual('/?search-param=true');
+ });
+
test('data-astro-prefetch="tap" should prefetch on tap', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/'));
expect(reqUrls).not.toContainEqual('/prefetch-tap');
diff --git a/packages/astro/e2e/react-component.test.js b/packages/astro/e2e/react-component.test.js
index b19a071d664a..361ee8d69a2a 100644
--- a/packages/astro/e2e/react-component.test.js
+++ b/packages/astro/e2e/react-component.test.js
@@ -46,7 +46,6 @@ test.describe('React client id generation', () => {
const hydratedId1 = await components.nth(2).getAttribute('id');
const clientOnlyId0 = await components.nth(3).getAttribute('id');
const clientOnlyId1 = await components.nth(4).getAttribute('id');
- console.log('ho ho', staticId, hydratedId0, hydratedId1, clientOnlyId0, clientOnlyId1);
expect(staticId).not.toEqual(hydratedId0);
expect(hydratedId0).not.toEqual(hydratedId1);
expect(hydratedId1).not.toEqual(clientOnlyId0);
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index e77222c0c165..778b55080b72 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -21,11 +21,17 @@ import type { TSConfig } from '../core/config/tsconfig.js';
import type { AstroCookies } from '../core/cookies/index.js';
import type { AstroIntegrationLogger, Logger, LoggerLevel } from '../core/logger/core.js';
import type { AstroDevOverlay, DevOverlayCanvas } from '../runtime/client/dev-overlay/overlay.js';
-import type { DevOverlayHighlight } from '../runtime/client/dev-overlay/ui-library/highlight.js';
import type { Icon } from '../runtime/client/dev-overlay/ui-library/icons.js';
-import type { DevOverlayToggle } from '../runtime/client/dev-overlay/ui-library/toggle.js';
-import type { DevOverlayTooltip } from '../runtime/client/dev-overlay/ui-library/tooltip.js';
-import type { DevOverlayWindow } from '../runtime/client/dev-overlay/ui-library/window.js';
+import type {
+ DevOverlayBadge,
+ DevOverlayButton,
+ DevOverlayCard,
+ DevOverlayHighlight,
+ DevOverlayIcon,
+ DevOverlayToggle,
+ DevOverlayTooltip,
+ DevOverlayWindow,
+} from '../runtime/client/dev-overlay/ui-library/index.js';
import type { AstroComponentFactory, AstroComponentInstance } from '../runtime/server/index.js';
import type { OmitIndexSignature, Simplify } from '../type-utils.js';
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
@@ -2512,6 +2518,8 @@ export type DevOverlayMetadata = Window &
typeof globalThis & {
__astro_dev_overlay__: {
root: string;
+ version: string;
+ debugInfo: string;
};
};
@@ -2523,5 +2531,9 @@ declare global {
'astro-dev-overlay-tooltip': DevOverlayTooltip;
'astro-dev-overlay-highlight': DevOverlayHighlight;
'astro-dev-overlay-toggle': DevOverlayToggle;
+ 'astro-dev-overlay-badge': DevOverlayBadge;
+ 'astro-dev-overlay-button': DevOverlayButton;
+ 'astro-dev-overlay-icon': DevOverlayIcon;
+ 'astro-dev-overlay-card': DevOverlayCard;
}
}
diff --git a/packages/astro/src/cli/info/index.ts b/packages/astro/src/cli/info/index.ts
index 46e7d3c6978c..c6586b28d2a4 100644
--- a/packages/astro/src/cli/info/index.ts
+++ b/packages/astro/src/cli/info/index.ts
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process';
import { arch, platform } from 'node:os';
import prompts from 'prompts';
import type yargs from 'yargs-parser';
+import type { AstroConfig, AstroUserConfig } from '../../@types/astro.js';
import { resolveConfig } from '../../core/config/index.js';
import { ASTRO_VERSION } from '../../core/constants.js';
import { flagsToAstroInlineConfig } from '../flags.js';
@@ -12,7 +13,13 @@ interface InfoOptions {
flags: yargs.Arguments;
}
-export async function printInfo({ flags }: InfoOptions) {
+export async function getInfoOutput({
+ userConfig,
+ print,
+}: {
+ userConfig: AstroUserConfig | AstroConfig;
+ print: boolean;
+}): Promise {
const rows: Array<[string, string | string[]]> = [
['Astro', `v${ASTRO_VERSION}`],
['Node', process.version],
@@ -20,9 +27,7 @@ export async function printInfo({ flags }: InfoOptions) {
['Package Manager', getPackageManager()],
];
- const inlineConfig = flagsToAstroInlineConfig(flags);
try {
- const { userConfig } = await resolveConfig(inlineConfig, 'info');
rows.push(['Output', userConfig.output ?? 'static']);
rows.push(['Adapter', userConfig.adapter?.name ?? 'none']);
const integrations = (userConfig?.integrations ?? [])
@@ -35,10 +40,17 @@ export async function printInfo({ flags }: InfoOptions) {
let output = '';
for (const [label, value] of rows) {
- output += printRow(label, value);
+ output += printRow(label, value, print);
}
- await copyToClipboard(output.trim());
+ return output.trim();
+}
+
+export async function printInfo({ flags }: InfoOptions) {
+ const { userConfig } = await resolveConfig(flagsToAstroInlineConfig(flags), 'info');
+ const output = await getInfoOutput({ userConfig, print: true });
+
+ await copyToClipboard(output);
}
async function copyToClipboard(text: string) {
@@ -105,7 +117,7 @@ function getPackageManager() {
}
const MAX_PADDING = 25;
-function printRow(label: string, value: string | string[]) {
+function printRow(label: string, value: string | string[], print: boolean) {
const padding = MAX_PADDING - label.length;
const [first, ...rest] = Array.isArray(value) ? value : [value];
let plaintext = `${label}${' '.repeat(padding)}${first}`;
@@ -117,6 +129,8 @@ function printRow(label: string, value: string | string[]) {
}
}
plaintext += '\n';
- console.log(richtext);
+ if (print) {
+ console.log(richtext);
+ }
return plaintext;
}
diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts
index 573efe5734ef..15f4ef0ccd95 100644
--- a/packages/astro/src/prefetch/index.ts
+++ b/packages/astro/src/prefetch/index.ts
@@ -226,7 +226,7 @@ function canPrefetchUrl(url: string, ignoreSlowConnection: boolean) {
const urlObj = new URL(url, location.href);
return (
location.origin === urlObj.origin &&
- location.pathname !== urlObj.pathname &&
+ (location.pathname !== urlObj.pathname || location.search !== urlObj.search) &&
!prefetchedUrls.has(url)
);
} catch {}
diff --git a/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts b/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts
index 65e50c98e03e..cbb5985180c4 100644
--- a/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts
@@ -14,11 +14,16 @@ document.addEventListener('DOMContentLoaded', async () => {
{ default: astroXrayPlugin },
{ default: astroSettingsPlugin },
{ AstroDevOverlay, DevOverlayCanvas },
- { DevOverlayCard },
- { DevOverlayHighlight },
- { DevOverlayTooltip },
- { DevOverlayWindow },
- { DevOverlayToggle },
+ {
+ DevOverlayCard,
+ DevOverlayHighlight,
+ DevOverlayTooltip,
+ DevOverlayWindow,
+ DevOverlayToggle,
+ DevOverlayButton,
+ DevOverlayBadge,
+ DevOverlayIcon,
+ },
{ getIconElement, isDefinedIcon },
] = await Promise.all([
// @ts-expect-error
@@ -28,12 +33,8 @@ document.addEventListener('DOMContentLoaded', async () => {
import('./plugins/xray.js'),
import('./plugins/settings.js'),
import('./overlay.js'),
- import('./ui-library/card.js'),
- import('./ui-library/highlight.js'),
- import('./ui-library/tooltip.js'),
- import('./ui-library/window.js'),
- import('./ui-library/toggle.js'),
- import('./ui-library/icons.js'),
+ import('./ui-library/index.js'),
+ import('./ui-library/icons.js'),
]);
// Register custom elements
@@ -44,6 +45,9 @@ document.addEventListener('DOMContentLoaded', async () => {
customElements.define('astro-dev-overlay-highlight', DevOverlayHighlight);
customElements.define('astro-dev-overlay-card', DevOverlayCard);
customElements.define('astro-dev-overlay-toggle', DevOverlayToggle);
+ customElements.define('astro-dev-overlay-button', DevOverlayButton);
+ customElements.define('astro-dev-overlay-badge', DevOverlayBadge);
+ customElements.define('astro-dev-overlay-icon', DevOverlayIcon);
overlay = document.createElement('astro-dev-overlay');
diff --git a/packages/astro/src/runtime/client/dev-overlay/overlay.ts b/packages/astro/src/runtime/client/dev-overlay/overlay.ts
index 900c3fb0fe60..0e89ae3e9263 100644
--- a/packages/astro/src/runtime/client/dev-overlay/overlay.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/overlay.ts
@@ -203,7 +203,7 @@ export class AstroDevOverlay extends HTMLElement {
transition: opacity 0.2s ease-in-out;
pointer-events: auto;
border: 0;
- color: white;
+ color: #13151A;
font-family: system-ui, sans-serif;
font-size: 1rem;
line-height: 1.2;
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts
index 352a018e11e2..951101bae8b2 100644
--- a/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts
@@ -1,19 +1,90 @@
-import type { DevOverlayPlugin } from '../../../../@types/astro.js';
+import type { DevOverlayMetadata, DevOverlayPlugin } from '../../../../@types/astro.js';
+import { isDefinedIcon, type Icon } from '../ui-library/icons.js';
+import { colorForIntegration, iconForIntegration } from './utils/icons.js';
import { createWindowWithTransition, waitForTransition } from './utils/window.js';
+const astroLogo =
+ '';
+
+export interface Integration {
+ name: string;
+ title: string;
+ description: string;
+ image?: string;
+ categories: string[];
+ repoUrl: string;
+ npmUrl: string;
+ homepageUrl: string;
+ official: boolean;
+ featured: number;
+ downloads: number;
+}
+
+interface IntegrationData {
+ data: Integration[];
+}
+
+let integrationData: IntegrationData;
+
export default {
id: 'astro',
name: 'Astro',
icon: 'astro:logo',
- init(canvas) {
+ async init(canvas, eventTarget) {
createWindow();
document.addEventListener('astro:after-swap', createWindow);
+ eventTarget.addEventListener('plugin-toggled', async (event) => {
+ resetDebugButton();
+ if (!(event instanceof CustomEvent)) return;
+
+ if (event.detail.state === true) {
+ if (!integrationData)
+ fetch('https://astro.build/api/v1/dev-overlay/', {
+ cache: 'no-cache',
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ integrationData = data;
+ integrationData.data = integrationData.data.map((integration) => {
+ return integration;
+ });
+ refreshIntegrationList();
+ });
+ }
+ });
+
function createWindow() {
- const window = createWindowWithTransition(
- 'Astro',
- 'astro:logo',
+ const links: { icon: Icon; name: string; link: string }[] = [
+ {
+ icon: 'bug',
+ name: 'Report a bug',
+ link: 'https://github.com/withastro/astro/issues/new/choose',
+ },
+ {
+ icon: 'lightbulb',
+ name: 'Suggestions',
+ link: 'https://github.com/withastro/roadmap/discussions/new/choose',
+ },
+ {
+ icon: 'file-search',
+ name: 'Docs',
+ link: 'https://docs.astro.build',
+ },
+ {
+ icon: 'star',
+ name: 'Star on GitHub',
+ link: 'https://github.com/withastro/astro',
+ },
+ {
+ icon: '',
+ name: 'Our Discord',
+ link: 'https://astro.build/chat',
+ },
+ ];
+
+ const windowComponent = createWindowWithTransition(
`
+
+
+ ${astroLogo}
+ Version ${
+ (window as DevOverlayMetadata).__astro_dev_overlay__.version
+ }
+
+ Get debug info
+
+
+
-
Welcome to Astro!
-
+
+
-
+
`
);
- canvas.append(window);
+ const copyDebugButton =
+ windowComponent.querySelector('#copy-debug-button');
+
+ copyDebugButton?.addEventListener('click', () => {
+ navigator.clipboard.writeText(
+ '```\n' + (window as DevOverlayMetadata).__astro_dev_overlay__.debugInfo + '\n```'
+ );
+ copyDebugButton.textContent = 'Copied to clipboard';
+ });
+
+ canvas.append(windowComponent);
+ }
+
+ function resetDebugButton() {
+ const copyDebugButton = canvas.querySelector('#copy-debug-button');
+ if (!copyDebugButton) return;
+
+ copyDebugButton.innerHTML = 'Get debug info ';
+ }
+
+ function refreshIntegrationList() {
+ const integrationList = canvas.querySelector('#integration-list');
+
+ if (!integrationList) return;
+ integrationList.innerHTML = '';
+
+ const fragment = document.createDocumentFragment();
+ for (const integration of integrationData.data) {
+ const integrationComponent = document.createElement('astro-dev-overlay-card');
+ integrationComponent.link = integration.homepageUrl;
+
+ const integrationContainer = document.createElement('div');
+ integrationContainer.className = 'integration-container';
+
+ const integrationImage = document.createElement('div');
+ integrationImage.className = 'integration-image';
+
+ if (integration.image) {
+ const img = document.createElement('img');
+ img.src = integration.image;
+ img.alt = integration.title;
+ integrationImage.append(img);
+ } else {
+ const icon = document.createElement('astro-dev-overlay-icon');
+ icon.icon = iconForIntegration(integration);
+ integrationImage.append(icon);
+ integrationImage.style.setProperty(
+ '--integration-image-background',
+ colorForIntegration()
+ );
+ }
+
+ integrationContainer.append(integrationImage);
+
+ let integrationTitle = document.createElement('h3');
+ integrationTitle.textContent = integration.title;
+ if (integration.official || integration.categories.includes('official')) {
+ integrationTitle.innerHTML +=
+ ' ';
+ }
+ integrationContainer.append(integrationTitle);
+
+ const integrationDescription = document.createElement('p');
+ integrationDescription.textContent =
+ integration.description.length > 90
+ ? integration.description.slice(0, 90) + '…'
+ : integration.description;
+
+ integrationContainer.append(integrationDescription);
+ integrationComponent.append(integrationContainer);
+
+ fragment.append(integrationComponent);
+ }
+
+ integrationList.append(fragment);
}
},
async beforeTogglingOff(canvas) {
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/settings.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/settings.ts
index e0d3384463ef..9f1a279007e2 100644
--- a/packages/astro/src/runtime/client/dev-overlay/plugins/settings.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/settings.ts
@@ -46,9 +46,11 @@ export default {
function createSettingsWindow() {
const window = createWindowWithTransition(
- 'Settings',
- 'gear',
`
+
+
+
+
General
`,
settingsRows.flatMap((setting) => [
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/utils/icons.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/icons.ts
new file mode 100644
index 000000000000..109b4f06dfdd
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/icons.ts
@@ -0,0 +1,43 @@
+import type { Integration } from '../astro.js';
+
+function randomFromArray(list: T[]) {
+ return list[Math.floor(Math.random() * list.length)];
+}
+
+const categoryIcons = new Map(
+ Object.entries({
+ frameworks: ['puzzle', 'grid'],
+ adapters: ['puzzle', 'grid', 'compress'],
+ 'css+ui': ['compress', 'grid', 'image', 'resizeImage', 'puzzle'],
+ 'performance+seo': ['approveUser', 'checkCircle', 'compress', 'robot', 'searchFile', 'sitemap'],
+ analytics: ['checkCircle', 'compress', 'searchFile'],
+ accessibility: ['approveUser', 'checkCircle'],
+ other: ['checkCircle', 'grid', 'puzzle', 'sitemap'],
+ })
+);
+
+export function iconForIntegration(integration: Integration) {
+ const icons = integration.categories
+ .filter((category: string) => categoryIcons.has(category))
+ .map((category: string) => categoryIcons.get(category)!)
+ .flat();
+
+ return randomFromArray(icons);
+}
+
+const iconColors = [
+ '#BC52EE',
+ '#6D6AF0',
+ '#52EEBD',
+ '#52B7EE',
+ '#52EE55',
+ '#B7EE52',
+ '#EEBD52',
+ '#EE5552',
+ '#EE52B7',
+ '#858B98',
+];
+
+export function colorForIntegration() {
+ return randomFromArray(iconColors)
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/utils/window.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/window.ts
index 04f09d6e6473..38055d72760d 100644
--- a/packages/astro/src/runtime/client/dev-overlay/plugins/utils/window.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/window.ts
@@ -1,48 +1,34 @@
-import type { Icon } from '../../ui-library/icons.js';
-
-export function createWindowWithTransition(
- title: string,
- icon: Icon,
- windowContent: string,
- addedNodes: Node[] = []
-): DocumentFragment {
- const fragment = document.createDocumentFragment();
-
- const style = document.createElement('style');
- style.textContent = `
- :host {
- opacity: 0;
- transition: opacity 0.15s ease-in-out;
- }
-
- :host([data-active]) {
- opacity: 1;
- }
-
- @media screen and (prefers-reduced-motion: no-preference) {
- :host astro-dev-overlay-window {
- transform: translateY(55px) translate(-50%, -50%);
- transition: transform 0.15s ease-in-out;
- transform-origin: center bottom;
- }
-
- :host([data-active]) astro-dev-overlay-window {
- transform: translateY(0) translate(-50%, -50%);
- }
- }
+export function createWindowWithTransition(windowContent: string, addedNodes: Node[] = []) {
+ const windowElement = document.createElement('astro-dev-overlay-window');
+ windowElement.innerHTML = `
+
+ ${windowContent}
`;
- fragment.append(style);
- const window = document.createElement('astro-dev-overlay-window');
- window.windowTitle = title;
- window.windowIcon = icon;
- window.innerHTML = windowContent;
+ windowElement.append(...addedNodes);
- window.append(...addedNodes);
-
- fragment.append(window);
-
- return fragment;
+ return windowElement;
}
export async function waitForTransition(canvas: ShadowRoot): Promise {
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/badge.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/badge.ts
new file mode 100644
index 000000000000..06d1e5031536
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/badge.ts
@@ -0,0 +1,71 @@
+type BadgeSize = 'small' | 'large';
+type BadgeStyle = 'purple' | 'gray' | 'red' | 'green' | 'yellow';
+
+export class DevOverlayBadge extends HTMLElement {
+ size: BadgeSize = 'small';
+ badgeStyle: BadgeStyle = 'purple';
+
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ if (this.hasAttribute('size')) this.size = this.getAttribute('size') as BadgeSize;
+
+ if (this.hasAttribute('badge-style'))
+ this.badgeStyle = this.getAttribute('badge-style') as BadgeStyle;
+
+ const classes = [`badge--${this.size}`, `badge--${this.badgeStyle}`];
+ this.shadowRoot.innerHTML = `
+
+
+
+
+
+ `;
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/button.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/button.ts
new file mode 100644
index 000000000000..7c39fdc1d64e
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/button.ts
@@ -0,0 +1,88 @@
+type ButtonSize = 'small' | 'medium' | 'large';
+type ButtonStyle = 'ghost' | 'outline' | 'purple' | 'gray' | 'red';
+
+export class DevOverlayButton extends HTMLElement {
+ size: ButtonSize = 'small';
+ buttonStyle: ButtonStyle = 'purple';
+
+ shadowRoot: ShadowRoot;
+
+ constructor() {
+ super();
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ if (this.hasAttribute('size')) this.size = this.getAttribute('size') as ButtonSize;
+
+ if (this.hasAttribute('button-style'))
+ this.buttonStyle = this.getAttribute('button-style') as ButtonStyle;
+
+ const classes = [`button--${this.size}`, `button--${this.buttonStyle}`];
+
+ this.shadowRoot.innerHTML = `
+
+
+
+ `;
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts
index 9d7062f8babf..90d4739f165f 100644
--- a/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts
@@ -1,8 +1,6 @@
-import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
-
export class DevOverlayCard extends HTMLElement {
- icon?: Icon;
link?: string | undefined | null;
+ clickAction?: () => void | (() => Promise);
shadowRoot: ShadowRoot;
constructor() {
@@ -10,26 +8,30 @@ export class DevOverlayCard extends HTMLElement {
this.shadowRoot = this.attachShadow({ mode: 'open' });
this.link = this.getAttribute('link');
- this.icon = this.hasAttribute('icon') ? (this.getAttribute('icon') as Icon) : undefined;
}
connectedCallback() {
- const element = this.link ? 'a' : 'button';
+ const element = this.link ? 'a' : this.clickAction ? 'button' : 'div';
this.shadowRoot.innerHTML = `
- <${element}${this.link ? ` href="${this.link}" target="_blank"` : ``}>
- ${this.icon ? this.getElementForIcon(this.icon) : ''}
-
+ <${element}${this.link ? ` href="${this.link}" target="_blank"` : ``} id="astro-overlay-card">
+
${element}>
`;
- }
- getElementForIcon(icon: Icon) {
- let iconElement;
- if (isDefinedIcon(icon)) {
- iconElement = getIconElement(icon);
- } else {
- iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- iconElement.setAttribute('viewBox', '0 0 16 16');
- iconElement.innerHTML = icon;
+ if (this.clickAction) {
+ this.shadowRoot
+ .getElementById('astro-overlay-card')
+ ?.addEventListener('click', this.clickAction);
}
-
- iconElement?.style.setProperty('height', '24px');
- iconElement?.style.setProperty('width', '24px');
-
- return iconElement?.outerHTML ?? '';
}
}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts
index 7d91535e0a40..6a1b914a8b01 100644
--- a/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts
@@ -26,6 +26,7 @@ export class DevOverlayHighlight extends HTMLElement {
.icon {
width: 24px;
height: 24px;
+ color: white;
background: linear-gradient(0deg, #B33E66, #B33E66), linear-gradient(0deg, #351722, #351722);
border: 1px solid rgba(53, 23, 34, 1);
border-radius: 9999px;
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/icon.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/icon.ts
new file mode 100644
index 000000000000..65ef7c27028d
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/icon.ts
@@ -0,0 +1,41 @@
+import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
+
+export class DevOverlayIcon extends HTMLElement {
+ _icon: Icon | undefined = undefined;
+ shadowRoot: ShadowRoot;
+
+ get icon() {
+ return this._icon;
+ }
+ set icon(name: Icon | undefined) {
+ this._icon = name;
+ this.buildTemplate();
+ }
+
+ constructor() {
+ super();
+
+ this.shadowRoot = this.attachShadow({ mode: 'open' });
+
+ if (this.hasAttribute('icon')) {
+ this.icon = this.getAttribute('icon') as Icon;
+ } else {
+ this.buildTemplate();
+ }
+ }
+
+ getIconHTML(icon: Icon | undefined) {
+ if (icon && isDefinedIcon(icon)) {
+ return getIconElement(icon)?.outerHTML ?? '';
+ }
+
+ // If the icon that was passed isn't one of the predefined one, assume that they're passing it in as a slot
+ return '';
+ }
+
+ buildTemplate() {
+ this.shadowRoot.innerHTML = `\n${this.getIconHTML(
+ this._icon
+ )}`;
+ }
+}
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts
index d9445e44acf8..26e77f176d95 100644
--- a/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts
@@ -22,15 +22,32 @@ export function getIconElement(
const icons = {
'astro:logo': ``,
- warning: ``,
+ warning: ``,
'arrow-down':
- '',
- bug: '',
- 'file-search':
- '',
+ '',
+ bug: '',
+ '': '',
'check-circle':
- '',
- gear: '',
+ '',
+ gear: '',
+ lightbulb:
+ '',
+ 'file-search':
+ '',
+ star: '',
+ checkmark:
+ '',
'dots-three':
'',
+ copy: '',
+ compress: '',
+ grid: '',
+ puzzle: '',
+ approveUser: '',
+ checkCircle: '',
+ resizeImage: '',
+ searchFile: '',
+ image: '',
+ robot: '',
+ sitemap: '',
} as const;
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/index.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/index.ts
new file mode 100644
index 000000000000..ba60ecf1e67e
--- /dev/null
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/index.ts
@@ -0,0 +1,8 @@
+export { DevOverlayBadge } from './badge.js';
+export { DevOverlayButton } from './button.js';
+export { DevOverlayCard } from './card.js';
+export { DevOverlayHighlight } from './highlight.js';
+export { DevOverlayTooltip } from './tooltip.js';
+export { DevOverlayWindow } from './window.js';
+export { DevOverlayToggle } from './toggle.js';
+export { DevOverlayIcon } from './icon.js';
diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts
index 18b515429ad8..2421632ae2ec 100644
--- a/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts
+++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts
@@ -1,18 +1,9 @@
-import { getIconElement, isDefinedIcon, type Icon } from './icons.js';
-
export class DevOverlayWindow extends HTMLElement {
- windowTitle?: string | undefined | null;
- windowIcon?: Icon | undefined | null;
shadowRoot: ShadowRoot;
constructor() {
super();
this.shadowRoot = this.attachShadow({ mode: 'open' });
-
- this.windowTitle = this.getAttribute('window-title');
- this.windowIcon = this.hasAttribute('window-icon')
- ? (this.getAttribute('window-icon') as Icon)
- : undefined;
}
async connectedCallback() {
@@ -43,15 +34,6 @@ export class DevOverlayWindow extends HTMLElement {
color: #fff;
}
- #window-title {
- display: flex;
- align-items: center;
- font-weight: 600;
- color: #fff;
- margin: 0;
- font-size: 22px;
- }
-
::slotted(h1) {
font-size: 22px;
}
@@ -72,37 +54,13 @@ export class DevOverlayWindow extends HTMLElement {
font-size: 14px;
}
- #window-title svg {
- margin-right: 8px;
- height: 1em;
- }
-
hr, ::slotted(hr) {
border: 1px solid rgba(27, 30, 36, 1);
margin: 1em 0;
}
- ${this.windowIcon ? this.getElementForIcon(this.windowIcon) : ''}${
- this.windowTitle ?? ''
- }
-
`;
}
-
- getElementForIcon(icon: Icon) {
- if (isDefinedIcon(icon)) {
- const iconElement = getIconElement(icon);
- iconElement?.style.setProperty('height', '1em');
-
- return iconElement?.outerHTML;
- } else {
- const iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- iconElement.setAttribute('viewBox', '0 0 16 16');
- iconElement.innerHTML = icon;
-
- return iconElement.outerHTML;
- }
- }
}
diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts
index 4c49525e7593..3323749dee9c 100644
--- a/packages/astro/src/vite-plugin-astro-server/route.ts
+++ b/packages/astro/src/vite-plugin-astro-server/route.ts
@@ -2,12 +2,15 @@ import type http from 'node:http';
import { fileURLToPath } from 'node:url';
import type {
ComponentInstance,
+ DevOverlayMetadata,
ManifestData,
MiddlewareHandler,
RouteData,
SSRElement,
SSRManifest,
} from '../@types/astro.js';
+import { getInfoOutput } from '../cli/info/index.js';
+import { ASTRO_VERSION } from '../core/constants.js';
import { AstroErrorData, isAstroError } from '../core/errors/index.js';
import { sequence } from '../core/middleware/index.js';
import { req } from '../core/messages.js';
@@ -390,12 +393,18 @@ async function getScriptsAndStyles({ pipeline, filePath }: GetScriptsAndStylesPa
children: '',
});
+ const additionalMetadata: DevOverlayMetadata['__astro_dev_overlay__'] = {
+ root: fileURLToPath(settings.config.root),
+ version: ASTRO_VERSION,
+ debugInfo: await getInfoOutput({ userConfig: settings.config, print: false }),
+ };
+
+ settings.config;
+
// Additional data for the dev overlay
scripts.add({
props: {},
- children: `window.__astro_dev_overlay__ = {root: ${JSON.stringify(
- fileURLToPath(settings.config.root)
- )}}`,
+ children: `window.__astro_dev_overlay__ = ${JSON.stringify(additionalMetadata)}`,
});
}
}
diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js
index 2c9b878138b8..52ebeda23ae1 100644
--- a/packages/astro/test/i18n-routing.test.js
+++ b/packages/astro/test/i18n-routing.test.js
@@ -701,7 +701,6 @@ describe('[SSG] i18n routing', () => {
it('should render the en locale', async () => {
let html = await fixture.readFile('/index.html');
- let $ = cheerio.load(html);
expect(html).to.include('http-equiv="refresh');
expect(html).to.include('Redirecting to: /en');
});
@@ -953,9 +952,8 @@ describe('[SSR] i18n routing', () => {
it('should redirect to the english locale, which is the first fallback', async () => {
let request = new Request('http://example.com/new-site/it/start');
let response = await app.render(request);
- console.log(await response.text());
- // expect(response.status).to.equal(302);
- // expect(response.headers.get('location')).to.equal('/new-site/start');
+ expect(response.status).to.equal(302);
+ expect(response.headers.get('location')).to.equal('/new-site/start');
});
it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => {
diff --git a/packages/astro/test/parallel.js b/packages/astro/test/parallel.test.js
similarity index 100%
rename from packages/astro/test/parallel.js
rename to packages/astro/test/parallel.test.js
diff --git a/packages/astro/test/ssr-split-manifest.test.js b/packages/astro/test/ssr-split-manifest.test.js
index 89c8e00ef898..74d2fe74e574 100644
--- a/packages/astro/test/ssr-split-manifest.test.js
+++ b/packages/astro/test/ssr-split-manifest.test.js
@@ -109,7 +109,6 @@ describe('astro:ssr-manifest, split', () => {
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
- console.log(html);
expect(html.includes('Testing')).to.be.true;
});
});
diff --git a/packages/astro/test/units/dev/dev.test.js b/packages/astro/test/units/dev/dev.test.js
index 5882947093f8..52fb203c3ad6 100644
--- a/packages/astro/test/units/dev/dev.test.js
+++ b/packages/astro/test/units/dev/dev.test.js
@@ -199,7 +199,6 @@ describe('dev container', () => {
container.handle(r.req, r.res);
await r.done;
const doc = await r.text();
- console.log(doc);
expect(doc).to.match(/Regular page/);
expect(r.res.statusCode).to.equal(200);
}
diff --git a/packages/astro/test/units/i18n/astro_i18n.js b/packages/astro/test/units/i18n/astro_i18n.test.js
similarity index 99%
rename from packages/astro/test/units/i18n/astro_i18n.js
rename to packages/astro/test/units/i18n/astro_i18n.test.js
index f90ad14e69ef..63e2df83326d 100644
--- a/packages/astro/test/units/i18n/astro_i18n.js
+++ b/packages/astro/test/units/i18n/astro_i18n.test.js
@@ -636,6 +636,7 @@ describe('getLocaleAbsoluteUrl', () => {
...config.experimental.i18n,
trailingSlash: 'never',
format: 'directory',
+ site: 'https://example.com',
})
).to.eq('https://example.com/blog');
expect(
@@ -645,6 +646,7 @@ describe('getLocaleAbsoluteUrl', () => {
...config.experimental.i18n,
trailingSlash: 'always',
format: 'directory',
+ site: 'https://example.com',
})
).to.eq('https://example.com/blog/es/');
@@ -655,6 +657,7 @@ describe('getLocaleAbsoluteUrl', () => {
...config.experimental.i18n,
trailingSlash: 'ignore',
format: 'directory',
+ site: 'https://example.com',
})
).to.eq('https://example.com/blog/');
@@ -666,6 +669,7 @@ describe('getLocaleAbsoluteUrl', () => {
...config.experimental.i18n,
trailingSlash: 'never',
format: 'file',
+ site: 'https://example.com',
})
).to.eq('https://example.com/blog');
expect(
@@ -675,6 +679,7 @@ describe('getLocaleAbsoluteUrl', () => {
...config.experimental.i18n,
trailingSlash: 'always',
format: 'file',
+ site: 'https://example.com',
})
).to.eq('https://example.com/blog/es/');
@@ -686,6 +691,7 @@ describe('getLocaleAbsoluteUrl', () => {
...config.experimental.i18n,
trailingSlash: 'ignore',
format: 'file',
+ site: 'https://example.com',
})
).to.eq('https://example.com/blog');
});
diff --git a/packages/create-astro/src/actions/dependencies.ts b/packages/create-astro/src/actions/dependencies.ts
index e920fcf8e482..26557d5a2f1d 100644
--- a/packages/create-astro/src/actions/dependencies.ts
+++ b/packages/create-astro/src/actions/dependencies.ts
@@ -51,6 +51,14 @@ async function install({ packageManager, cwd }: { packageManager: string; cwd: s
return shell(packageManager, ['install'], { cwd, timeout: 90_000, stdio: 'ignore' });
}
+/**
+ * Yarn Berry (PnP) versions will throw an error if there isn't an existing `yarn.lock` file
+ * If a `yarn.lock` file doesn't exist, this function writes an empty `yarn.lock` one.
+ * Unfortunately this hack is required to run `yarn install`.
+ *
+ * The empty `yarn.lock` file is immediately overwritten by the installation process.
+ * See https://github.com/withastro/astro/pull/8028
+ */
async function ensureYarnLock({ cwd }: { cwd: string }) {
const yarnLock = path.join(cwd, 'yarn.lock');
if (fs.existsSync(yarnLock)) return;
diff --git a/packages/create-astro/test/typescript.test.js b/packages/create-astro/test/typescript.test.js
index 498d3384b8a8..461a3ed63745 100644
--- a/packages/create-astro/test/typescript.test.js
+++ b/packages/create-astro/test/typescript.test.js
@@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url';
import { typescript, setupTypeScript } from '../dist/index.js';
import { setup, resetFixtures } from './utils.js';
-import { describe } from 'node:test';
describe('typescript', () => {
const fixture = setup();
diff --git a/packages/integrations/mdx/test/mdx-slots.js b/packages/integrations/mdx/test/mdx-slots.test.js
similarity index 100%
rename from packages/integrations/mdx/test/mdx-slots.js
rename to packages/integrations/mdx/test/mdx-slots.test.js
diff --git a/packages/integrations/node/test/bad-urls.test.js b/packages/integrations/node/test/bad-urls.test.js
index de7484b808e1..894729e3679b 100644
--- a/packages/integrations/node/test/bad-urls.test.js
+++ b/packages/integrations/node/test/bad-urls.test.js
@@ -2,7 +2,7 @@ import { expect } from 'chai';
import nodejs from '../dist/index.js';
import { loadFixture } from './test-utils.js';
-describe('API routes', () => {
+describe('Bad URLs', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
let devPreview;
diff --git a/packages/integrations/node/test/headers.test.js.js b/packages/integrations/node/test/headers.test.js
similarity index 100%
rename from packages/integrations/node/test/headers.test.js.js
rename to packages/integrations/node/test/headers.test.js
diff --git a/packages/upgrade/README.md b/packages/upgrade/README.md
new file mode 100644
index 000000000000..3744671f72cb
--- /dev/null
+++ b/packages/upgrade/README.md
@@ -0,0 +1,53 @@
+# @astrojs/upgrade
+
+A command-line tool for upgrading your Astro integrations and dependencies.
+
+You can run this command in your terminal to upgrade your official Astro integrations at the same time you upgrade your version of Astro.
+
+## Usage
+
+`@astrojs/upgrade` should not be added as a dependency to your project, but run as a temporary executable whenever you want to upgrade using [`npx`](https://docs.npmjs.com/cli/v10/commands/npx) or [`dlx`](https://pnpm.io/cli/dlx).
+
+**With NPM:**
+
+```bash
+npx @astrojs/upgrade
+```
+
+**With Yarn:**
+
+```bash
+yarn dlx @astrojs/upgrade
+```
+
+**With PNPM:**
+
+```bash
+pnpm dlx @astrojs/upgrade
+```
+
+## Options
+
+### tag (optional)
+
+It is possible to pass a specific `tag` to resolve packages against. If not included, `@astrojs/upgrade` looks for the `latest` tag.
+
+For example, Astro often releases `beta` versions prior to an upcoming major release. Upgrade an existing Astro project and it's dependencies to the `beta` version using one of the following commands:
+
+**With NPM:**
+
+```bash
+npx @astrojs/upgrade beta
+```
+
+**With Yarn:**
+
+```bash
+yarn dlx @astrojs/upgrade beta
+```
+
+**With PNPM:**
+
+```bash
+pnpm dlx @astrojs/upgrade beta
+```
diff --git a/packages/upgrade/package.json b/packages/upgrade/package.json
new file mode 100644
index 000000000000..b4b851c88a21
--- /dev/null
+++ b/packages/upgrade/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@astrojs/upgrade",
+ "version": "0.0.1",
+ "type": "module",
+ "author": "withastro",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/withastro/astro.git",
+ "directory": "packages/upgrade"
+ },
+ "bugs": "https://github.com/withastro/astro/issues",
+ "homepage": "https://astro.build",
+ "exports": {
+ ".": "./upgrade.mjs"
+ },
+ "main": "./upgrade.mjs",
+ "bin": "./upgrade.mjs",
+ "scripts": {
+ "build": "astro-scripts build \"src/index.ts\" --bundle && tsc",
+ "build:ci": "astro-scripts build \"src/index.ts\" --bundle",
+ "dev": "astro-scripts dev \"src/**/*.ts\"",
+ "test": "mocha --exit --timeout 20000 --parallel"
+ },
+ "files": [
+ "dist",
+ "upgrade.js"
+ ],
+ "//a": "MOST PACKAGES SHOULD GO IN DEV_DEPENDENCIES! THEY WILL BE BUNDLED.",
+ "//b": "DEPENDENCIES IS FOR UNBUNDLED PACKAGES",
+ "dependencies": {
+ "@astrojs/cli-kit": "^0.2.3",
+ "semver": "^7.5.4",
+ "which-pm-runs": "^1.1.0",
+ "terminal-link": "^3.0.0"
+ },
+ "devDependencies": {
+ "@types/semver": "^7.5.2",
+ "@types/which-pm-runs": "^1.0.0",
+ "arg": "^5.0.2",
+ "astro-scripts": "workspace:*",
+ "chai": "^4.3.7",
+ "mocha": "^10.2.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18.14.1"
+ }
+}
diff --git a/packages/upgrade/src/actions/context.ts b/packages/upgrade/src/actions/context.ts
new file mode 100644
index 000000000000..f821864b5ea7
--- /dev/null
+++ b/packages/upgrade/src/actions/context.ts
@@ -0,0 +1,56 @@
+import { prompt } from '@astrojs/cli-kit';
+import arg from 'arg';
+import { pathToFileURL } from 'node:url';
+import detectPackageManager from 'which-pm-runs';
+
+export interface Context {
+ help: boolean;
+ prompt: typeof prompt;
+ version: string;
+ dryRun?: boolean;
+ cwd: URL;
+ stdin?: typeof process.stdin;
+ stdout?: typeof process.stdout;
+ packageManager: string;
+ packages: PackageInfo[];
+ exit(code: number): never;
+}
+
+export interface PackageInfo {
+ name: string;
+ currentVersion: string;
+ targetVersion: string;
+ tag?: string;
+ isDevDependency?: boolean;
+ isMajor?: boolean;
+ changelogURL?: string;
+ changelogTitle?: string;
+}
+
+export async function getContext(argv: string[]): Promise {
+ const flags = arg(
+ {
+ '--dry-run': Boolean,
+ '--help': Boolean,
+
+ '-h': '--help',
+ },
+ { argv, permissive: true }
+ );
+
+ const packageManager = detectPackageManager()?.name ?? 'npm';
+ const { _: [version = 'latest'] = [], '--help': help = false, '--dry-run': dryRun } = flags;
+
+ return {
+ help,
+ prompt,
+ packageManager,
+ packages: [],
+ cwd: new URL(pathToFileURL(process.cwd()) + '/'),
+ dryRun,
+ version,
+ exit(code) {
+ process.exit(code);
+ },
+ } satisfies Context;
+}
diff --git a/packages/upgrade/src/actions/help.ts b/packages/upgrade/src/actions/help.ts
new file mode 100644
index 000000000000..2e25b7e8442b
--- /dev/null
+++ b/packages/upgrade/src/actions/help.ts
@@ -0,0 +1,15 @@
+import { printHelp } from '../messages.js';
+
+export function help() {
+ printHelp({
+ commandName: '@astrojs/upgrade',
+ usage: '[version] [...flags]',
+ headline: 'Upgrade Astro dependencies.',
+ tables: {
+ Flags: [
+ ['--help (-h)', 'See all available flags.'],
+ ['--dry-run', 'Walk through steps without executing.'],
+ ],
+ },
+ });
+}
diff --git a/packages/upgrade/src/actions/install.ts b/packages/upgrade/src/actions/install.ts
new file mode 100644
index 000000000000..3f343463fadf
--- /dev/null
+++ b/packages/upgrade/src/actions/install.ts
@@ -0,0 +1,187 @@
+import type { Context, PackageInfo } from './context.js';
+
+import { color, say } from '@astrojs/cli-kit';
+import { random, sleep } from '@astrojs/cli-kit/utils';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ banner,
+ bye,
+ celebrations,
+ changelog,
+ done,
+ error,
+ info,
+ newline,
+ pluralize,
+ spinner,
+ success,
+ title,
+ upgrade,
+ warn,
+} from '../messages.js';
+import { shell } from '../shell.js';
+
+export async function install(
+ ctx: Pick<
+ Context,
+ 'version' | 'packages' | 'packageManager' | 'prompt' | 'dryRun' | 'exit' | 'cwd'
+ >
+) {
+ await banner();
+ newline();
+ const { current, dependencies, devDependencies } = filterPackages(ctx);
+ const toInstall = [...dependencies, ...devDependencies].sort(sortPackages);
+ for (const packageInfo of current.sort(sortPackages)) {
+ const tag = /^\d/.test(packageInfo.targetVersion)
+ ? packageInfo.targetVersion
+ : packageInfo.targetVersion.slice(1);
+ await info(`${packageInfo.name}`, `is up to date on`, `v${tag}`);
+ await sleep(random(50, 150));
+ }
+ if (toInstall.length === 0 && !ctx.dryRun) {
+ newline();
+ await success(random(celebrations), random(done));
+ return;
+ }
+ const majors: PackageInfo[] = [];
+ for (const packageInfo of toInstall) {
+ const word = ctx.dryRun ? 'can' : 'will';
+ await upgrade(packageInfo, `${word} be updated to`);
+ if (packageInfo.isMajor) {
+ majors.push(packageInfo);
+ }
+ }
+ if (majors.length > 0) {
+ const { proceed } = await ctx.prompt({
+ name: 'proceed',
+ type: 'confirm',
+ label: title('wait'),
+ message: `${pluralize(
+ ['One package has', 'Some packages have'],
+ majors.length
+ )} breaking changes. Continue?`,
+ initial: true,
+ });
+ if (!proceed) {
+ return ctx.exit(0);
+ }
+
+ newline();
+
+ await warn('check', `Be sure to follow the ${pluralize('CHANGELOG', majors.length)}.`);
+ for (const pkg of majors.sort(sortPackages)) {
+ await changelog(pkg.name, pkg.changelogTitle!, pkg.changelogURL!);
+ }
+ }
+
+ newline();
+ if (ctx.dryRun) {
+ await info('--dry-run', `Skipping dependency installation`);
+ } else {
+ await runInstallCommand(ctx, dependencies, devDependencies);
+ }
+}
+
+function filterPackages(ctx: Pick) {
+ const current: PackageInfo[] = [];
+ const dependencies: PackageInfo[] = [];
+ const devDependencies: PackageInfo[] = [];
+ for (const packageInfo of ctx.packages) {
+ const { currentVersion, targetVersion, isDevDependency } = packageInfo;
+ // Remove prefix from `currentVersion` before comparing
+ if (currentVersion.replace(/^\D+/, '') === targetVersion) {
+ current.push(packageInfo);
+ } else {
+ const arr = isDevDependency ? devDependencies : dependencies;
+ arr.push(packageInfo);
+ }
+ }
+ return { current, dependencies, devDependencies };
+}
+
+/**
+ * An `Array#sort` comparator function to normalize how packages are displayed.
+ * This only changes how the packages are displayed in the CLI, it is not persisted to `package.json`.
+ */
+function sortPackages(a: PackageInfo, b: PackageInfo): number {
+ if (a.isMajor && !b.isMajor) return 1;
+ if (b.isMajor && !a.isMajor) return -1;
+ if (a.name === 'astro') return -1;
+ if (b.name === 'astro') return 1;
+ if (a.name.startsWith('@astrojs') && !b.name.startsWith('@astrojs')) return -1;
+ if (b.name.startsWith('@astrojs') && !a.name.startsWith('@astrojs')) return 1;
+ return a.name.localeCompare(b.name);
+}
+
+async function runInstallCommand(
+ ctx: Pick,
+ dependencies: PackageInfo[],
+ devDependencies: PackageInfo[]
+) {
+ const cwd = fileURLToPath(ctx.cwd);
+ if (ctx.packageManager === 'yarn') await ensureYarnLock({ cwd });
+
+ await spinner({
+ start: `Installing dependencies with ${ctx.packageManager}...`,
+ end: `Installed dependencies!`,
+ while: async () => {
+ try {
+ if (dependencies.length > 0) {
+ await shell(
+ ctx.packageManager,
+ [
+ 'install',
+ ...dependencies.map(
+ ({ name, targetVersion }) => `${name}@${targetVersion.replace(/^\^/, '')}`
+ ),
+ ],
+ { cwd, timeout: 90_000, stdio: 'ignore' }
+ );
+ }
+ if (devDependencies.length > 0) {
+ await shell(
+ ctx.packageManager,
+ [
+ 'install',
+ '--save-dev',
+ ...devDependencies.map(
+ ({ name, targetVersion }) => `${name}@${targetVersion.replace(/^\^/, '')}`
+ ),
+ ],
+ { cwd, timeout: 90_000, stdio: 'ignore' }
+ );
+ }
+ } catch {
+ const packages = [...dependencies, ...devDependencies]
+ .map(({ name, targetVersion }) => `${name}@${targetVersion}`)
+ .join(' ');
+ newline();
+ error(
+ 'error',
+ `Dependencies failed to install, please run the following command manually:\n${color.bold(
+ `${ctx.packageManager} install ${packages}`
+ )}`
+ );
+ return ctx.exit(1);
+ }
+ },
+ });
+
+ await say([`${random(celebrations)} ${random(done)}`, random(bye)], { clear: false });
+}
+
+/**
+ * Yarn Berry (PnP) versions will throw an error if there isn't an existing `yarn.lock` file
+ * If a `yarn.lock` file doesn't exist, this function writes an empty `yarn.lock` one.
+ * Unfortunately this hack is required to run `yarn install`.
+ *
+ * The empty `yarn.lock` file is immediately overwritten by the installation process.
+ * See https://github.com/withastro/astro/pull/8028
+ */
+async function ensureYarnLock({ cwd }: { cwd: string }) {
+ const yarnLock = path.join(cwd, 'yarn.lock');
+ if (fs.existsSync(yarnLock)) return;
+ return fs.promises.writeFile(yarnLock, '', { encoding: 'utf-8' });
+}
diff --git a/packages/upgrade/src/actions/verify.ts b/packages/upgrade/src/actions/verify.ts
new file mode 100644
index 000000000000..82daa9a06b14
--- /dev/null
+++ b/packages/upgrade/src/actions/verify.ts
@@ -0,0 +1,182 @@
+import type { Context, PackageInfo } from './context.js';
+
+import { color } from '@astrojs/cli-kit';
+import dns from 'node:dns/promises';
+import { existsSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
+import semverCoerce from 'semver/functions/coerce.js';
+import semverDiff from 'semver/functions/diff.js';
+import semverParse from 'semver/functions/parse.js';
+import { bannerAbort, error, getRegistry, info, newline } from '../messages.js';
+
+export async function verify(
+ ctx: Pick
+) {
+ const registry = await getRegistry();
+
+ if (!ctx.dryRun) {
+ const online = await isOnline(registry);
+ if (!online) {
+ bannerAbort();
+ newline();
+ error('error', `Unable to connect to the internet.`);
+ ctx.exit(1);
+ }
+ }
+
+ await verifyAstroProject(ctx);
+
+ const ok = await verifyVersions(ctx, registry);
+ if (!ok) {
+ bannerAbort();
+ newline();
+ error('error', `Version ${color.reset(ctx.version)} ${color.dim('could not be found!')}`);
+ await info('check', 'https://github.com/withastro/astro/releases');
+ ctx.exit(1);
+ }
+}
+
+function isOnline(registry: string): Promise {
+ const { host } = new URL(registry);
+ return dns.lookup(host).then(
+ () => true,
+ () => false
+ );
+}
+
+function safeJSONParse(value: string) {
+ try {
+ return JSON.parse(value);
+ } catch {}
+ return {};
+}
+
+async function verifyAstroProject(ctx: Pick) {
+ const packageJson = new URL('./package.json', ctx.cwd);
+ if (!existsSync(packageJson)) return false;
+ const contents = await readFile(packageJson, { encoding: 'utf-8' });
+ if (!contents.includes('astro')) return false;
+
+ const { dependencies = {}, devDependencies = {} } = safeJSONParse(contents);
+ if (dependencies['astro'] === undefined && devDependencies['astro'] === undefined) return false;
+
+ // Side-effect! Persist dependency info to the shared context
+ collectPackageInfo(ctx, dependencies, devDependencies);
+
+ return true;
+}
+
+function isAstroPackage(name: string) {
+ return name === 'astro' || name.startsWith('@astrojs/');
+}
+
+function collectPackageInfo(
+ ctx: Pick,
+ dependencies: Record,
+ devDependencies: Record
+) {
+ for (const [name, currentVersion] of Object.entries(dependencies)) {
+ if (!isAstroPackage(name)) continue;
+ ctx.packages.push({
+ name,
+ currentVersion,
+ targetVersion: ctx.version,
+ });
+ }
+ for (const [name, currentVersion] of Object.entries(devDependencies)) {
+ if (!isAstroPackage(name)) continue;
+ ctx.packages.push({
+ name,
+ currentVersion,
+ targetVersion: ctx.version,
+ isDevDependency: true,
+ });
+ }
+}
+
+async function verifyVersions(
+ ctx: Pick,
+ registry: string
+) {
+ const tasks: Promise[] = [];
+ for (const packageInfo of ctx.packages) {
+ tasks.push(resolveTargetVersion(packageInfo, registry));
+ }
+ try {
+ await Promise.all(tasks);
+ } catch {
+ return false;
+ }
+ for (const packageInfo of ctx.packages) {
+ if (!packageInfo.targetVersion) {
+ return false;
+ }
+ }
+ return true;
+}
+
+async function resolveTargetVersion(packageInfo: PackageInfo, registry: string): Promise {
+ const packageMetadata = await fetch(`${registry}/${packageInfo.name}`, {
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
+ });
+ if (packageMetadata.status >= 400) {
+ throw new Error(`Unable to resolve "${packageInfo.name}"`);
+ }
+ const { 'dist-tags': distTags } = await packageMetadata.json();
+ let version = distTags[packageInfo.targetVersion];
+ if (version) {
+ packageInfo.tag = packageInfo.targetVersion;
+ packageInfo.targetVersion = version;
+ } else {
+ packageInfo.targetVersion = 'latest';
+ version = distTags.latest;
+ }
+ if (packageInfo.currentVersion === version) {
+ return;
+ }
+ const prefix = packageInfo.targetVersion === 'latest' ? '^' : '';
+ packageInfo.targetVersion = `${prefix}${version}`;
+ const fromVersion = semverCoerce(packageInfo.currentVersion)!;
+ const toVersion = semverParse(version)!;
+ const bump = semverDiff(fromVersion, toVersion);
+ if ((bump === 'major' && toVersion.prerelease.length === 0) || bump === 'premajor') {
+ packageInfo.isMajor = true;
+ if (packageInfo.name === 'astro') {
+ const upgradeGuide = `https://docs.astro.build/en/guides/upgrade-to/v${toVersion.major}/`;
+ const docsRes = await fetch(upgradeGuide);
+ // OK if this request fails, it's probably a prerelease without a public migration guide.
+ // In that case, we should fallback to the CHANGELOG check below.
+ if (docsRes.status === 200) {
+ packageInfo.changelogURL = upgradeGuide;
+ packageInfo.changelogTitle = `Upgrade to Astro v${toVersion.major}`;
+ return;
+ }
+ }
+ const latestMetadata = await fetch(`${registry}/${packageInfo.name}/latest`);
+ if (latestMetadata.status >= 400) {
+ throw new Error(`Unable to resolve "${packageInfo.name}"`);
+ }
+ const { repository } = await latestMetadata.json();
+ const branch = bump === 'premajor' ? 'next' : 'main';
+ packageInfo.changelogURL = extractChangelogURLFromRepository(repository, version, branch);
+ packageInfo.changelogTitle = 'CHANGELOG';
+ } else {
+ // Dependency updates should not include the specific dist-tag
+ // since they are just for compatability
+ packageInfo.tag = undefined;
+ }
+}
+
+function extractChangelogURLFromRepository(
+ repository: Record,
+ version: string,
+ branch = 'main'
+) {
+ return (
+ repository.url.replace('git+', '').replace('.git', '') +
+ `/blob/${branch}/` +
+ repository.directory +
+ '/CHANGELOG.md#' +
+ version.replace(/\./g, '')
+ );
+}
diff --git a/packages/upgrade/src/index.ts b/packages/upgrade/src/index.ts
new file mode 100644
index 000000000000..8131e6eb3d8d
--- /dev/null
+++ b/packages/upgrade/src/index.ts
@@ -0,0 +1,32 @@
+import { getContext } from './actions/context.js';
+
+import { help } from './actions/help.js';
+import { install } from './actions/install.js';
+import { verify } from './actions/verify.js';
+import { setStdout } from './messages.js';
+
+const exit = () => process.exit(0);
+process.on('SIGINT', exit);
+process.on('SIGTERM', exit);
+
+export async function main() {
+ // NOTE: In the v7.x version of npm, the default behavior of `npm init` was changed
+ // to no longer require `--` to pass args and instead pass `--` directly to us. This
+ // broke our arg parser, since `--` is a special kind of flag. Filtering for `--` here
+ // fixes the issue so that create-astro now works on all npm versions.
+ const cleanArgv = process.argv.slice(2).filter((arg) => arg !== '--');
+ const ctx = await getContext(cleanArgv);
+ if (ctx.help) {
+ help();
+ return;
+ }
+
+ const steps = [verify, install];
+
+ for (const step of steps) {
+ await step(ctx);
+ }
+ process.exit(0);
+}
+
+export { getContext, install, setStdout, verify };
diff --git a/packages/upgrade/src/messages.ts b/packages/upgrade/src/messages.ts
new file mode 100644
index 000000000000..e159a6f0631e
--- /dev/null
+++ b/packages/upgrade/src/messages.ts
@@ -0,0 +1,212 @@
+/* eslint no-console: 'off' */
+import { color, label, spinner as load } from '@astrojs/cli-kit';
+import { align } from '@astrojs/cli-kit/utils';
+import semverParse from 'semver/functions/parse.js';
+import terminalLink from 'terminal-link';
+import detectPackageManager from 'which-pm-runs';
+import type { PackageInfo } from './actions/context.js';
+import { shell } from './shell.js';
+
+// Users might lack access to the global npm registry, this function
+// checks the user's project type and will return the proper npm registry
+//
+// A copy of this function also exists in the astro package
+export async function getRegistry(): Promise {
+ const packageManager = detectPackageManager()?.name || 'npm';
+ try {
+ const { stdout } = await shell(packageManager, ['config', 'get', 'registry']);
+ return stdout?.trim()?.replace(/\/$/, '') || 'https://registry.npmjs.org';
+ } catch (e) {
+ return 'https://registry.npmjs.org';
+ }
+}
+
+let stdout = process.stdout;
+/** @internal Used to mock `process.stdout.write` for testing purposes */
+export function setStdout(writable: typeof process.stdout) {
+ stdout = writable;
+}
+
+export async function spinner(args: {
+ start: string;
+ end: string;
+ while: (...args: any) => Promise;
+}) {
+ await load(args, { stdout });
+}
+
+export function pluralize(word: string | [string, string], n: number) {
+ const [singular, plural] = Array.isArray(word) ? word : [word, word + 's'];
+ if (n === 1) return singular;
+ return plural;
+}
+
+export const celebrations = [
+ 'Beautiful.',
+ 'Excellent!',
+ 'Sweet!',
+ 'Nice!',
+ 'Huzzah!',
+ 'Success.',
+ 'Nice.',
+ 'Wonderful.',
+ 'Lovely!',
+ "Lookin' good.",
+ 'Awesome.',
+];
+
+export const done = [
+ "You're on the latest and greatest.",
+ 'Your integrations are up-to-date.',
+ 'Everything is current.',
+ 'Everything is up to date.',
+ 'Integrations are all up to date.',
+ 'Everything is on the latest and greatest.',
+ 'Integrations are up to date.',
+];
+
+export const bye = [
+ 'Thanks for using Astro!',
+ 'Have fun building!',
+ 'Take it easy, astronaut!',
+ "Can't wait to see what you build.",
+ 'Good luck out there.',
+ 'See you around, astronaut.',
+];
+
+export const log = (message: string) => stdout.write(message + '\n');
+
+export const newline = () => stdout.write('\n');
+
+export const banner = async () =>
+ log(
+ `\n${label('astro', color.bgGreen, color.black)} ${color.bold(
+ 'Integration upgrade in progress.'
+ )}`
+ );
+
+export const bannerAbort = () =>
+ log(`\n${label('astro', color.bgRed)} ${color.bold('Integration upgrade aborted.')}`);
+
+export const warn = async (prefix: string, text: string) => {
+ log(`${label(prefix, color.bgCyan, color.black)} ${text}`);
+};
+
+export const info = async (prefix: string, text: string, version = '') => {
+ const length = 11 + prefix.length + text.length + version?.length;
+ const symbol = '◼';
+ if (length > stdout.columns) {
+ log(`${' '.repeat(5)} ${color.cyan(symbol)} ${prefix}`);
+ log(`${' '.repeat(9)}${color.dim(text)} ${color.reset(version)}`);
+ } else {
+ log(
+ `${' '.repeat(5)} ${color.cyan(symbol)} ${prefix} ${color.dim(text)} ${color.reset(version)}`
+ );
+ }
+};
+export const upgrade = async (packageInfo: PackageInfo, text: string) => {
+ const { name, isMajor = false, targetVersion } = packageInfo;
+
+ const bg = isMajor ? (v: string) => color.bgYellow(color.black(` ${v} `)) : color.green;
+ const style = isMajor ? color.yellow : color.green;
+ const symbol = isMajor ? '▲' : '●';
+ const toVersion = semverParse(targetVersion)!;
+ const version = `v${toVersion.version}`;
+
+ const length = 12 + name.length + text.length + version.length;
+ if (length > stdout.columns) {
+ log(`${' '.repeat(5)} ${style(symbol)} ${name}`);
+ log(`${' '.repeat(9)}${color.dim(text)} ${bg(version)}`);
+ } else {
+ log(`${' '.repeat(5)} ${style(symbol)} ${name} ${color.dim(text)} ${bg(version)}`);
+ }
+};
+
+export const title = (text: string) =>
+ align(label(text, color.bgYellow, color.black), 'end', 7) + ' ';
+
+export const success = async (prefix: string, text: string) => {
+ const length = 10 + prefix.length + text.length;
+ if (length > stdout.columns) {
+ log(`${' '.repeat(5)} ${color.green('✔')} ${prefix}`);
+ log(`${' '.repeat(9)}${color.dim(text)}`);
+ } else {
+ log(`${' '.repeat(5)} ${color.green('✔')} ${prefix} ${color.dim(text)}`);
+ }
+};
+
+export const error = async (prefix: string, text: string) => {
+ if (stdout.columns < 80) {
+ log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)}`);
+ log(`${' '.repeat(9)}${color.dim(text)}`);
+ } else {
+ log(`${' '.repeat(5)} ${color.red('▲')} ${color.red(prefix)} ${color.dim(text)}`);
+ }
+};
+
+export const changelog = async (name: string, text: string, url: string) => {
+ const link = terminalLink(text, url, { fallback: () => url });
+ const linkLength = terminalLink.isSupported ? text.length : url.length;
+ const symbol = ' ';
+
+ const length = 12 + name.length + linkLength;
+ if (length > stdout.columns) {
+ log(`${' '.repeat(5)} ${symbol} ${name}`);
+ log(`${' '.repeat(9)}${color.cyan(color.underline(link))}`);
+ } else {
+ log(`${' '.repeat(5)} ${symbol} ${name} ${color.cyan(color.underline(link))}`);
+ }
+};
+
+export function printHelp({
+ commandName,
+ usage,
+ tables,
+ description,
+}: {
+ commandName: string;
+ headline?: string;
+ usage?: string;
+ tables?: Record;
+ description?: string;
+}) {
+ const linebreak = () => '';
+ const table = (rows: [string, string][], { padding }: { padding: number }) => {
+ const split = stdout.columns < 60;
+ let raw = '';
+
+ for (const row of rows) {
+ if (split) {
+ raw += ` ${row[0]}\n `;
+ } else {
+ raw += `${`${row[0]}`.padStart(padding)}`;
+ }
+ raw += ' ' + color.dim(row[1]) + '\n';
+ }
+
+ return raw.slice(0, -1); // remove latest \n
+ };
+
+ let message = [];
+
+ if (usage) {
+ message.push(linebreak(), `${color.green(commandName)} ${color.bold(usage)}`);
+ }
+
+ if (tables) {
+ function calculateTablePadding(rows: [string, string][]) {
+ return rows.reduce((val, [first]) => Math.max(val, first.length), 0);
+ }
+ const tableEntries = Object.entries(tables);
+ const padding = Math.max(...tableEntries.map(([, rows]) => calculateTablePadding(rows)));
+ for (const [, tableRows] of tableEntries) {
+ message.push(linebreak(), table(tableRows, { padding }));
+ }
+ }
+
+ if (description) {
+ message.push(linebreak(), `${description}`);
+ }
+
+ log(message.join('\n') + '\n');
+}
diff --git a/packages/upgrade/src/shell.ts b/packages/upgrade/src/shell.ts
new file mode 100644
index 000000000000..65a83327d8b8
--- /dev/null
+++ b/packages/upgrade/src/shell.ts
@@ -0,0 +1,60 @@
+// This is an extremely simplified version of [`execa`](https://github.com/sindresorhus/execa)
+// intended to keep our dependency size down
+import type { ChildProcess, StdioOptions } from 'node:child_process';
+import type { Readable } from 'node:stream';
+
+import { spawn } from 'node:child_process';
+import { text as textFromStream } from 'node:stream/consumers';
+
+export interface ExecaOptions {
+ cwd?: string | URL;
+ stdio?: StdioOptions;
+ timeout?: number;
+}
+export interface Output {
+ stdout: string;
+ stderr: string;
+ exitCode: number;
+}
+const text = (stream: NodeJS.ReadableStream | Readable | null) =>
+ stream ? textFromStream(stream).then((t) => t.trimEnd()) : '';
+
+let signal: AbortSignal;
+export async function shell(
+ command: string,
+ flags: string[],
+ opts: ExecaOptions = {}
+): Promise