diff --git a/.changeset/large-stingrays-fry.md b/.changeset/large-stingrays-fry.md new file mode 100644 index 0000000000..a70e377143 --- /dev/null +++ b/.changeset/large-stingrays-fry.md @@ -0,0 +1,21 @@ +--- +'astro': minor +--- + + +Dev Overlay (experimental) + +Provides a new dev overlay for your browser preview that allows you to inspect your page islands, see helpful audits on performance and accessibility, and more. A Dev Overlay Plugin API is also included to allow you to add new features and third-party integrations to it. + +You can enable access to the dev overlay and its API by adding the following flag to your Astro config: + +```ts +// astro.config.mjs +export default { + experimental: { + devOverlay: true + } +}; +``` + +Read the [Dev Overlay Plugin API documentation](https://docs.astro.build/en/reference/dev-overlay-plugin-reference/) for information about building your own plugins to integrate with Astro's dev overlay. diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 70c4b0ffa7..e6085ac647 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,5 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires const { builtinModules } = require('module'); +/** @type {import("@types/eslint").Linter.Config} */ module.exports = { extends: [ 'plugin:@typescript-eslint/recommended-type-checked', @@ -74,6 +76,12 @@ module.exports = { ], }, }, + { + files: ['packages/astro/src/runtime/client/**/*.ts'], + env: { + browser: true, + }, + }, { files: ['packages/**/test/*.js', 'packages/**/*.js'], env: { diff --git a/.github/scripts/bundle-size.mjs b/.github/scripts/bundle-size.mjs index 66911eab10..f1c9ceab02 100644 --- a/.github/scripts/bundle-size.mjs +++ b/.github/scripts/bundle-size.mjs @@ -68,6 +68,7 @@ async function bundle(files) { sourcemap: false, target: ['es2018'], outdir: 'out', + external: ['astro:*'], metafile: true, }) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index a0fa48ddb2..1bcbe20a9c 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -21,6 +21,7 @@ import type { TSConfig } from '../core/config/tsconfig.js'; import type { AstroCookies } from '../core/cookies/index.js'; import type { ResponseWithEncoding } from '../core/endpoint/index.js'; import type { AstroIntegrationLogger, Logger, LoggerLevel } from '../core/logger/core.js'; +import type { Icon } from '../runtime/client/dev-overlay/ui-library/icons.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'; @@ -1351,6 +1352,25 @@ export interface AstroUserConfig { * ``` */ optimizeHoistedScript?: boolean; + + /** + * @docs + * @name experimental.devOverlay + * @type {boolean} + * @default `false` + * @version 3.4.0 + * @description + * Enable a dev overlay in development mode. This overlay allows you to inspect your page islands, see helpful audits on performance and accessibility, and more. + * + * ```js + * { + * experimental: { + * devOverlay: true, + * }, + * } + * ``` + */ + devOverlay?: boolean; }; } @@ -1524,6 +1544,7 @@ export interface AstroSettings { * Map of directive name (e.g. `load`) to the directive script code */ clientDirectives: Map; + devOverlayPlugins: string[]; tsConfig: TSConfig | undefined; tsConfigPath: string | undefined; watchFiles: string[]; @@ -2049,6 +2070,7 @@ export interface AstroIntegration { injectScript: (stage: InjectedScriptStage, content: string) => void; injectRoute: (injectRoute: InjectedRoute) => void; addClientDirective: (directive: ClientDirectiveConfig) => void; + addDevOverlayPlugin: (entrypoint: string) => void; logger: AstroIntegrationLogger; // TODO: Add support for `injectElement()` for full HTML element injection, not just scripts. // This may require some refactoring of `scripts`, `styles`, and `links` into something @@ -2284,3 +2306,17 @@ export interface ClientDirectiveConfig { name: string; entrypoint: string; } + +export interface DevOverlayPlugin { + id: string; + name: string; + icon: Icon; + init?(canvas: ShadowRoot, eventTarget: EventTarget): void | Promise; +} + +export type DevOverlayMetadata = Window & + typeof globalThis & { + __astro_dev_overlay__: { + root: string; + }; + }; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 82fbdc3b4a..ee470abc8f 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -55,6 +55,7 @@ const ASTRO_CONFIG_DEFAULTS = { redirects: {}, experimental: { optimizeHoistedScript: false, + devOverlay: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -297,6 +298,7 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.optimizeHoistedScript), + devOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.devOverlay), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.` diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index 07f22a33c0..cf4db7598e 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -98,6 +98,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { scripts: [], clientDirectives: getDefaultClientDirectives(), watchFiles: [], + devOverlayPlugins: [], timer: new AstroTimer(), }; } diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index fd23a27f55..6a459be2a1 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -16,6 +16,7 @@ import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.j import { vitePluginAstroServer } from '../vite-plugin-astro-server/index.js'; import astroVitePlugin from '../vite-plugin-astro/index.js'; import configAliasVitePlugin from '../vite-plugin-config-alias/index.js'; +import astroDevOverlay from '../vite-plugin-dev-overlay/vite-plugin-dev-overlay.js'; import envVitePlugin from '../vite-plugin-env/index.js'; import astroHeadPlugin from '../vite-plugin-head/index.js'; import htmlVitePlugin from '../vite-plugin-html/index.js'; @@ -134,6 +135,7 @@ export async function createVite( vitePluginSSRManifest(), astroAssetsPlugin({ settings, logger, mode }), astroTransitions(), + astroDevOverlay({ settings, logger }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 5485794c57..268721025f 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -125,6 +125,9 @@ export async function runHookConfigSetup({ addWatchFile: (path) => { updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path); }, + addDevOverlayPlugin: (entrypoint) => { + updatedSettings.devOverlayPlugins.push(entrypoint); + }, addClientDirective: ({ name, entrypoint }) => { if (updatedSettings.clientDirectives.has(name) || addedClientDirectives.has(name)) { throw new Error( diff --git a/packages/astro/src/runtime/client/dev-overlay/overlay.ts b/packages/astro/src/runtime/client/dev-overlay/overlay.ts new file mode 100644 index 0000000000..57ca72d633 --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/overlay.ts @@ -0,0 +1,505 @@ +/* eslint-disable no-console */ +// @ts-expect-error +import { loadDevOverlayPlugins } from 'astro:dev-overlay'; +import type { DevOverlayPlugin as DevOverlayPluginDefinition } from '../../../@types/astro.js'; +import astroDevToolPlugin from './plugins/astro.js'; +import astroAuditPlugin from './plugins/audit.js'; +import astroXrayPlugin from './plugins/xray.js'; +import { DevOverlayCard } from './ui-library/card.js'; +import { DevOverlayHighlight } from './ui-library/highlight.js'; +import { getIconElement, isDefinedIcon, type Icon } from './ui-library/icons.js'; +import { DevOverlayTooltip } from './ui-library/tooltip.js'; +import { DevOverlayWindow } from './ui-library/window.js'; + +type DevOverlayPlugin = DevOverlayPluginDefinition & { + active: boolean; + status: 'ready' | 'loading' | 'error'; + eventTarget: EventTarget; +}; + +document.addEventListener('DOMContentLoaded', async () => { + const WS_EVENT_NAME = 'astro-dev-overlay'; + const HOVER_DELAY = 750; + + const builtinPlugins: DevOverlayPlugin[] = [ + astroDevToolPlugin, + astroXrayPlugin, + astroAuditPlugin, + ].map((plugin) => ({ + ...plugin, + active: false, + status: 'loading', + eventTarget: new EventTarget(), + })); + + const customPluginsImports = (await loadDevOverlayPlugins()) as DevOverlayPluginDefinition[]; + const customPlugins: DevOverlayPlugin[] = []; + customPlugins.push( + ...customPluginsImports.map((plugin) => ({ + ...plugin, + active: false, + status: 'loading' as const, + eventTarget: new EventTarget(), + })) + ); + + const plugins: DevOverlayPlugin[] = [...builtinPlugins, ...customPlugins]; + + for (const plugin of plugins) { + plugin.eventTarget.addEventListener('plugin-notification', (evt) => { + const target = overlay.shadowRoot?.querySelector(`[data-plugin-id="${plugin.id}"]`); + if (!target) return; + + let newState = true; + if (evt instanceof CustomEvent) { + newState = evt.detail.state ?? true; + } + + target.querySelector('.notification')?.toggleAttribute('data-active', newState); + }); + } + + class AstroDevOverlay extends HTMLElement { + shadowRoot: ShadowRoot; + hoverTimeout: number | undefined; + isHidden: () => boolean = () => this.devOverlay?.hasAttribute('data-hidden') ?? true; + devOverlay: HTMLDivElement | undefined; + + constructor() { + super(); + this.shadowRoot = this.attachShadow({ mode: 'closed' }); + } + + // connect component + async connectedCallback() { + this.shadowRoot.innerHTML = ` + + +
+
+
+ ${builtinPlugins.map((plugin) => this.getPluginTemplate(plugin)).join('')} +
+ ${customPlugins.map((plugin) => this.getPluginTemplate(plugin)).join('')} +
+
+ +
`; + + this.devOverlay = this.shadowRoot.querySelector('#dev-overlay')!; + this.attachEvents(); + + // Init plugin lazily + if ('requestIdleCallback' in window) { + window.requestIdleCallback(async () => { + await this.initAllPlugins(); + }); + } else { + // Fallback to setTimeout for.. Safari... + setTimeout(async () => { + await this.initAllPlugins(); + }, 200); + } + } + + attachEvents() { + const items = this.shadowRoot.querySelectorAll('.item'); + items.forEach((item) => { + item.addEventListener('click', async (e) => { + const target = e.currentTarget; + if (!target || !(target instanceof HTMLElement)) return; + + const id = target.dataset.pluginId; + if (!id) return; + + const plugin = this.getPluginById(id); + if (!plugin) return; + + if (plugin.status === 'loading') { + await this.initPlugin(plugin); + } + + this.togglePluginStatus(plugin); + }); + }); + + const minimizeButton = this.shadowRoot.querySelector('#minimize-button'); + if (minimizeButton && this.devOverlay) { + minimizeButton.addEventListener('click', () => { + this.toggleOverlay(false); + this.toggleMinimizeButton(false); + }); + } + + const devBar = this.shadowRoot.querySelector('#dev-bar'); + if (devBar) { + // On hover: + // - If the overlay is hidden, show it after the hover delay + // - If the overlay is visible, show the minimize button after the hover delay + (['mouseenter', 'focusin'] as const).forEach((event) => { + devBar.addEventListener(event, () => { + if (this.hoverTimeout) { + window.clearTimeout(this.hoverTimeout); + } + + if (this.isHidden()) { + this.hoverTimeout = window.setTimeout(() => { + this.toggleOverlay(true); + }, HOVER_DELAY); + } else { + this.hoverTimeout = window.setTimeout(() => { + this.toggleMinimizeButton(true); + }, HOVER_DELAY); + } + }); + }); + + // On unhover: + // - Reset every timeout, as to avoid showing the overlay/minimize button when the user didn't really want to hover + // - If the overlay is visible, hide the minimize button after the hover delay + devBar.addEventListener('mouseleave', () => { + if (this.hoverTimeout) { + window.clearTimeout(this.hoverTimeout); + } + + if (!this.isHidden()) { + this.hoverTimeout = window.setTimeout(() => { + this.toggleMinimizeButton(false); + }, HOVER_DELAY); + } + }); + + // On click, show the overlay if it's hidden, it's likely the user wants to interact with it + devBar.addEventListener('click', () => { + if (!this.isHidden()) return; + this.toggleOverlay(true); + }); + + devBar.addEventListener('keyup', (event) => { + if (event.code === 'Space' || event.code === 'Enter') { + if (!this.isHidden()) return; + this.toggleOverlay(true); + } + }); + } + } + + async initAllPlugins() { + await Promise.all( + plugins + .filter((plugin) => plugin.status === 'loading') + .map((plugin) => this.initPlugin(plugin)) + ); + } + + async initPlugin(plugin: DevOverlayPlugin) { + if (plugin.status === 'ready') return; + + const shadowRoot = this.getPluginCanvasById(plugin.id)!.shadowRoot!; + + try { + console.info(`Initing plugin ${plugin.id}`); + await plugin.init?.(shadowRoot, plugin.eventTarget); + plugin.status = 'ready'; + + if (import.meta.hot) { + import.meta.hot.send(`${WS_EVENT_NAME}:${plugin.id}:init`); + } + } catch (e) { + console.error(`Failed to init plugin ${plugin.id}, error: ${e}`); + plugin.status = 'error'; + } + } + + getPluginTemplate(plugin: DevOverlayPlugin) { + return ``; + } + + getPluginIcon(icon: Icon) { + if (isDefinedIcon(icon)) { + return getIconElement(icon)?.outerHTML; + } + + return icon; + } + + getPluginById(id: string) { + return plugins.find((plugin) => plugin.id === id); + } + + getPluginCanvasById(id: string) { + return this.shadowRoot.querySelector(`astro-overlay-plugin-canvas[data-plugin-id="${id}"]`); + } + + togglePluginStatus(plugin: DevOverlayPlugin, status?: boolean) { + plugin.active = status ?? !plugin.active; + const target = this.shadowRoot.querySelector(`[data-plugin-id="${plugin.id}"]`); + if (!target) return; + target.classList.toggle('active', plugin.active); + this.getPluginCanvasById(plugin.id)?.toggleAttribute('data-active', plugin.active); + + plugin.eventTarget.dispatchEvent( + new CustomEvent('plugin-toggle', { + detail: { + state: plugin.active, + plugin, + }, + }) + ); + + if (import.meta.hot) { + import.meta.hot.send(`${WS_EVENT_NAME}:${plugin.id}:toggle`, { state: plugin.active }); + } + } + + toggleMinimizeButton(newStatus?: boolean) { + const minimizeButton = this.shadowRoot.querySelector('#minimize-button'); + if (!minimizeButton) return; + + if (newStatus !== undefined) { + if (newStatus === true) { + minimizeButton.removeAttribute('inert'); + minimizeButton.style.opacity = '1'; + } else { + minimizeButton.setAttribute('inert', ''); + minimizeButton.style.opacity = '0'; + } + } else { + minimizeButton.toggleAttribute('inert'); + minimizeButton.style.opacity = minimizeButton.hasAttribute('inert') ? '0' : '1'; + } + } + + toggleOverlay(newStatus?: boolean) { + const barContainer = this.shadowRoot.querySelector('#bar-container'); + const devBar = this.shadowRoot.querySelector('#dev-bar'); + + if (newStatus !== undefined) { + if (newStatus === true) { + this.devOverlay?.removeAttribute('data-hidden'); + barContainer?.removeAttribute('inert'); + devBar?.removeAttribute('tabindex'); + } else { + this.devOverlay?.setAttribute('data-hidden', ''); + barContainer?.setAttribute('inert', ''); + devBar?.setAttribute('tabindex', '0'); + } + } else { + this.devOverlay?.toggleAttribute('data-hidden'); + barContainer?.toggleAttribute('inert'); + if (this.isHidden()) { + devBar?.setAttribute('tabindex', '0'); + } else { + devBar?.removeAttribute('tabindex'); + } + } + } + } + + class DevOverlayCanvas extends HTMLElement { + shadowRoot: ShadowRoot; + + constructor() { + super(); + this.shadowRoot = this.attachShadow({ mode: 'closed' }); + } + + // connect component + async connectedCallback() { + this.shadowRoot.innerHTML = ``; + } + } + + customElements.define('astro-dev-overlay', AstroDevOverlay); + customElements.define('astro-overlay-window', DevOverlayWindow); + customElements.define('astro-overlay-plugin-canvas', DevOverlayCanvas); + customElements.define('astro-overlay-tooltip', DevOverlayTooltip); + customElements.define('astro-overlay-highlight', DevOverlayHighlight); + customElements.define('astro-overlay-card', DevOverlayCard); + + const overlay = document.createElement('astro-dev-overlay'); + overlay.style.zIndex = '999999'; + document.body.append(overlay); + + // Create plugin canvases + plugins.forEach((plugin) => { + const pluginCanvas = document.createElement('astro-overlay-plugin-canvas'); + pluginCanvas.dataset.pluginId = plugin.id; + overlay.shadowRoot?.append(pluginCanvas); + }); +}); diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts new file mode 100644 index 0000000000..3629776ea5 --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/plugins/astro.ts @@ -0,0 +1,69 @@ +import type { DevOverlayPlugin } from '../../../../@types/astro.js'; +import type { DevOverlayWindow } from '../ui-library/window.js'; + +export default { + id: 'astro', + name: 'Astro', + icon: 'astro:logo', + init(canvas) { + const astroWindow = document.createElement('astro-overlay-window') as DevOverlayWindow; + + astroWindow.windowTitle = 'Astro'; + astroWindow.windowIcon = 'astro:logo'; + + astroWindow.innerHTML = ` + + +
+
+

Welcome to Astro!

+
+ Report an issue + View Astro Docs +
+
+ +
+ `; + + canvas.append(astroWindow); + }, +} satisfies DevOverlayPlugin; diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts new file mode 100644 index 0000000000..bb94ead94c --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/plugins/audit.ts @@ -0,0 +1,94 @@ +import type { DevOverlayPlugin } from '../../../../@types/astro.js'; +import type { DevOverlayHighlight } from '../ui-library/highlight.js'; +import type { DevOverlayTooltip } from '../ui-library/tooltip.js'; +import { attachTooltipToHighlight, createHighlight, positionHighlight } from './utils/highlight.js'; + +const icon = + ''; + +interface AuditRule { + title: string; + message: string; +} + +const selectorBasedRules: (AuditRule & { selector: string })[] = [ + { + title: 'Missing `alt` tag', + message: 'The alt attribute is important for accessibility.', + selector: 'img:not([alt])', + }, +]; + +export default { + id: 'astro:audit', + name: 'Audit', + icon: icon, + init(canvas, eventTarget) { + let audits: { highlightElement: DevOverlayHighlight; auditedElement: HTMLElement }[] = []; + + selectorBasedRules.forEach((rule) => { + document.querySelectorAll(rule.selector).forEach((el) => { + createAuditProblem(rule, el); + }); + }); + + if (audits.length > 0) { + eventTarget.dispatchEvent( + new CustomEvent('plugin-notification', { + detail: { + state: true, + }, + }) + ); + } + + function createAuditProblem(rule: AuditRule, originalElement: Element) { + const computedStyle = window.getComputedStyle(originalElement); + const targetedElement = (originalElement.children[0] as HTMLElement) || originalElement; + + // If the element is hidden, don't do anything + if (targetedElement.offsetParent === null || computedStyle.display === 'none') { + return; + } + + const rect = originalElement.getBoundingClientRect(); + const highlight = createHighlight(rect, 'warning'); + const tooltip = buildAuditTooltip(rule); + attachTooltipToHighlight(highlight, tooltip, originalElement); + + canvas.append(highlight); + audits.push({ highlightElement: highlight, auditedElement: originalElement as HTMLElement }); + + (['scroll', 'resize'] as const).forEach((event) => { + window.addEventListener(event, () => { + audits.forEach(({ highlightElement, auditedElement }) => { + const newRect = auditedElement.getBoundingClientRect(); + positionHighlight(highlightElement, newRect); + }); + }); + }); + } + + function buildAuditTooltip(rule: AuditRule) { + const tooltip = document.createElement('astro-overlay-tooltip') as DevOverlayTooltip; + tooltip.sections = [ + { + icon: 'warning', + title: rule.title, + }, + { + content: rule.message, + }, + // TODO: Add a link to the file + // Needs https://github.com/withastro/compiler/pull/375 + // { + // content: '/src/somewhere/component.astro', + // clickDescription: 'Click to go to file', + // clickAction() {}, + // }, + ]; + + return tooltip; + } + }, +} satisfies DevOverlayPlugin; diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts new file mode 100644 index 0000000000..34bfd1f5af --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/plugins/utils/highlight.ts @@ -0,0 +1,50 @@ +import type { DevOverlayHighlight } from '../../ui-library/highlight.js'; +import type { Icon } from '../../ui-library/icons.js'; + +export function createHighlight(rect: DOMRect, icon?: Icon) { + const highlight = document.createElement('astro-overlay-highlight') as DevOverlayHighlight; + if (icon) highlight.icon = icon; + + highlight.tabIndex = 0; + + positionHighlight(highlight, rect); + return highlight; +} + +export function positionHighlight(highlight: DevOverlayHighlight, rect: DOMRect) { + // Make an highlight that is 10px bigger than the element on all sides + highlight.style.top = `${Math.max(rect.top + window.scrollY - 10, 0)}px`; + highlight.style.left = `${Math.max(rect.left + window.scrollX - 10, 0)}px`; + highlight.style.width = `${rect.width + 15}px`; + highlight.style.height = `${rect.height + 15}px`; +} + +export function attachTooltipToHighlight( + highlight: DevOverlayHighlight, + tooltip: HTMLElement, + originalElement: Element +) { + highlight.shadowRoot.append(tooltip); + + (['mouseover', 'focus'] as const).forEach((event) => { + highlight.addEventListener(event, () => { + tooltip.dataset.show = 'true'; + const originalRect = originalElement.getBoundingClientRect(); + const dialogRect = tooltip.getBoundingClientRect(); + + // If the tooltip is going to be off the screen, show it above the element instead + if (originalRect.top < dialogRect.height) { + // Not enough space above, show below + tooltip.style.top = `${originalRect.height + 15}px`; + } else { + tooltip.style.top = `-${tooltip.offsetHeight}px`; + } + }); + }); + + (['mouseout', 'blur'] as const).forEach((event) => { + highlight.addEventListener(event, () => { + tooltip.dataset.show = 'false'; + }); + }); +} diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts new file mode 100644 index 0000000000..123cab8f3f --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts @@ -0,0 +1,103 @@ +import type { DevOverlayMetadata, DevOverlayPlugin } from '../../../../@types/astro.js'; +import type { DevOverlayHighlight } from '../ui-library/highlight.js'; +import type { DevOverlayTooltip } from '../ui-library/tooltip.js'; +import { attachTooltipToHighlight, createHighlight, positionHighlight } from './utils/highlight.js'; + +const icon = + ''; + +export default { + id: 'astro:xray', + name: 'Xray', + icon: icon, + init(canvas) { + let islandsOverlays: { highlightElement: DevOverlayHighlight; island: HTMLElement }[] = []; + addIslandsOverlay(); + + function addIslandsOverlay() { + const islands = document.querySelectorAll('astro-island'); + + islands.forEach((island) => { + const computedStyle = window.getComputedStyle(island); + const islandElement = (island.children[0] as HTMLElement) || island; + + // If the island is hidden, don't show an overlay on it + if (islandElement.offsetParent === null || computedStyle.display === 'none') { + return; + } + + const rect = islandElement.getBoundingClientRect(); + const highlight = createHighlight(rect); + const tooltip = buildIslandTooltip(island); + attachTooltipToHighlight(highlight, tooltip, islandElement); + + canvas.append(highlight); + islandsOverlays.push({ highlightElement: highlight, island: islandElement }); + }); + + (['scroll', 'resize'] as const).forEach((event) => { + window.addEventListener(event, () => { + islandsOverlays.forEach(({ highlightElement, island: islandElement }) => { + const newRect = islandElement.getBoundingClientRect(); + positionHighlight(highlightElement, newRect); + }); + }); + }); + } + + function buildIslandTooltip(island: HTMLElement) { + const tooltip = document.createElement('astro-overlay-tooltip') as DevOverlayTooltip; + tooltip.sections = []; + + const islandProps = island.getAttribute('props') + ? JSON.parse(island.getAttribute('props')!) + : {}; + const islandClientDirective = island.getAttribute('client'); + + // Add the component client's directive if we have one + if (islandClientDirective) { + tooltip.sections.push({ + title: 'Client directive', + inlineTitle: `client:${islandClientDirective}`, + }); + } + + // Add the props if we have any + if (Object.keys(islandProps).length > 0) { + tooltip.sections.push({ + title: 'Props', + content: `${Object.entries(islandProps) + .map((prop) => `${prop[0]}=${getPropValue(prop[1] as any)}`) + .join(', ')}`, + }); + } + + // Add a click action to go to the file + const islandComponentPath = island.getAttribute('component-url'); + if (islandComponentPath) { + tooltip.sections.push({ + content: islandComponentPath, + clickDescription: 'Click to go to file', + async clickAction() { + // NOTE: The path here has to be absolute and without any errors (no double slashes etc) + // or Vite will silently fail to open the file. Quite annoying. + await fetch( + '/__open-in-editor?file=' + + encodeURIComponent( + (window as DevOverlayMetadata).__astro_dev_overlay__.root + + islandComponentPath.slice(1) + ) + ); + }, + }); + } + + return tooltip; + } + + function getPropValue(prop: [number, any]) { + const [_, value] = prop; + return JSON.stringify(value, null, 2); + } + }, +} satisfies DevOverlayPlugin; 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 new file mode 100644 index 0000000000..debba97868 --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/card.ts @@ -0,0 +1,72 @@ +import { getIconElement, isDefinedIcon, type Icon } from './icons.js'; + +export class DevOverlayCard extends HTMLElement { + icon?: Icon; + link?: string | undefined | null; + shadowRoot: ShadowRoot; + + constructor() { + super(); + 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'; + + this.shadowRoot.innerHTML = ` + + + <${element}${this.link ? ` href="${this.link}" target="_blank"` : ``}> + ${this.icon ? this.getElementForIcon(this.icon) : ''} + + + `; + } + + 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; + } + + 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 new file mode 100644 index 0000000000..7d91535e0a --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/highlight.ts @@ -0,0 +1,66 @@ +import { getIconElement, isDefinedIcon, type Icon } from './icons.js'; + +export class DevOverlayHighlight extends HTMLElement { + icon?: Icon | undefined | null; + + shadowRoot: ShadowRoot; + + constructor() { + super(); + this.shadowRoot = this.attachShadow({ mode: 'open' }); + + this.icon = this.hasAttribute('icon') ? (this.getAttribute('icon') as Icon) : undefined; + + this.shadowRoot.innerHTML = ` + + `; + } + + connectedCallback() { + if (this.icon) { + let iconContainer = document.createElement('div'); + iconContainer.classList.add('icon'); + + let iconElement; + if (isDefinedIcon(this.icon)) { + iconElement = getIconElement(this.icon); + } else { + iconElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + iconElement.setAttribute('viewBox', '0 0 16 16'); + iconElement.innerHTML = this.icon; + } + + if (iconElement) { + iconElement?.style.setProperty('width', '16px'); + iconElement?.style.setProperty('height', '16px'); + + iconContainer.append(iconElement); + this.shadowRoot.append(iconContainer); + } + } + } +} 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 new file mode 100644 index 0000000000..a471249b3a --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/icons.ts @@ -0,0 +1,28 @@ +export type DefinedIcon = keyof typeof icons; +export type Icon = DefinedIcon | (string & NonNullable); + +export function isDefinedIcon(icon: Icon): icon is DefinedIcon { + return icon in icons; +} + +export function getIconElement( + name: keyof typeof icons | (string & NonNullable) +): SVGElement | undefined { + const icon = icons[name as keyof typeof icons]; + + if (!icon) { + return undefined; + } + + const svgFragment = new DocumentFragment(); + svgFragment.append(document.createRange().createContextualFragment(icon)); + + return svgFragment.firstElementChild as SVGElement; +} + +const icons = { + 'astro:logo': ``, + warning: ``, + 'arrow-down': + '', +} as const; diff --git a/packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts b/packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts new file mode 100644 index 0000000000..63244ab6bb --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/tooltip.ts @@ -0,0 +1,157 @@ +import { getIconElement, isDefinedIcon, type Icon } from './icons.js'; + +export interface DevOverlayTooltipSection { + title?: string; + inlineTitle?: string; + icon?: Icon; + content?: string; + clickAction?: () => void | Promise; + clickDescription?: string; +} + +export class DevOverlayTooltip extends HTMLElement { + sections: DevOverlayTooltipSection[] = []; + shadowRoot: ShadowRoot; + + constructor() { + super(); + this.shadowRoot = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot.innerHTML = ` + + +

${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 069c2ffe87..163fcdd043 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -1,4 +1,5 @@ import type http from 'node:http'; +import { fileURLToPath } from 'node:url'; import type { ComponentInstance, ManifestData, @@ -12,7 +13,7 @@ import { loadMiddleware } from '../core/middleware/loadMiddleware.js'; import { createRenderContext, getParamsAndProps, type SSROptions } from '../core/render/index.js'; import { createRequest } from '../core/request.js'; import { matchAllRoutes } from '../core/routing/index.js'; -import { isPage } from '../core/util.js'; +import { isPage, resolveIdToUrl } from '../core/util.js'; import { getSortedPreloadedMatches } from '../prerender/routing.js'; import { isServerLikeOutput } from '../prerender/utils.js'; import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; @@ -275,6 +276,24 @@ async function getScriptsAndStyles({ pipeline, filePath }: GetScriptsAndStylesPa props: { type: 'module', src: '/@vite/client' }, children: '', }); + + if (settings.config.experimental.devOverlay) { + scripts.add({ + props: { + type: 'module', + src: await resolveIdToUrl(moduleLoader, 'astro/runtime/client/dev-overlay/overlay.js'), + }, + children: '', + }); + + // Additional data for the dev overlay + scripts.add({ + props: {}, + children: `window.__astro_dev_overlay__ = {root: ${JSON.stringify( + fileURLToPath(settings.config.root) + )}}`, + }); + } } // TODO: We should allow adding generic HTML elements to the head, not just scripts diff --git a/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts b/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts new file mode 100644 index 0000000000..5c3aabe5ac --- /dev/null +++ b/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts @@ -0,0 +1,27 @@ +import type * as vite from 'vite'; +import type { AstroPluginOptions } from '../@types/astro.js'; + +const VIRTUAL_MODULE_ID = 'astro:dev-overlay'; +const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; + +export default function astroDevOverlay({ settings }: AstroPluginOptions): vite.Plugin { + return { + name: 'astro:dev-overlay', + resolveId(id) { + if (id === VIRTUAL_MODULE_ID) { + return resolvedVirtualModuleId; + } + }, + async load(id) { + if (id === resolvedVirtualModuleId) { + return ` + export const loadDevOverlayPlugins = async () => { + return [${settings.devOverlayPlugins + .map((plugin) => `(await import('${plugin}')).default`) + .join(',')}]; + }; + `; + } + }, + }; +}