diff options
20 files changed, 1341 insertions, 1 deletions
diff --git a/.changeset/large-stingrays-fry.md b/.changeset/large-stingrays-fry.md new file mode 100644 index 000000000..a70e37714 --- /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 70c4b0ffa..e6085ac64 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', @@ -75,6 +77,12 @@ module.exports = { }, }, { + files: ['packages/astro/src/runtime/client/**/*.ts'], + env: { + browser: true, + }, + }, + { files: ['packages/**/test/*.js', 'packages/**/*.js'], env: { mocha: true, diff --git a/.github/scripts/bundle-size.mjs b/.github/scripts/bundle-size.mjs index 66911eab1..f1c9ceab0 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 a0fa48ddb..1bcbe20a9 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<string, string>; + 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<void>; +} + +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 82fbdc3b4..ee470abc8 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 07f22a33c..cf4db7598 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 fd23a27f5..6a459be2a 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 5485794c5..268721025 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 000000000..57ca72d63 --- /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 = ` + <style> + #dev-overlay { + position: fixed; + bottom: 7.5%; + left: calc(50% + 32px); + transform: translate(-50%, 0%); + z-index: 999999; + display: flex; + gap: 8px; + align-items: center; + transition: bottom 0.2s ease-in-out; + pointer-events: none; + } + + #dev-overlay[data-hidden] { + bottom: -40px; + } + + #dev-overlay[data-hidden]:hover, #dev-overlay[data-hidden]:focus-within { + bottom: -35px; + cursor: pointer; + } + + #dev-overlay[data-hidden] #minimize-button { + visibility: hidden; + } + + #dev-bar { + height: 56px; + overflow: hidden; + pointer-events: auto; + + background: linear-gradient(180deg, #13151A 0%, rgba(19, 21, 26, 0.88) 100%); + box-shadow: 0px 0px 0px 0px #13151A4D; + border: 1px solid #343841; + border-radius: 9999px; + } + + #dev-bar .item { + display: flex; + justify-content: center; + align-items: center; + width: 64px; + border: 0; + background: transparent; + color: white; + font-family: system-ui, sans-serif; + font-size: 1rem; + line-height: 1.2; + white-space: nowrap; + text-decoration: none; + padding: 0; + margin: 0; + overflow: hidden; + } + + #dev-bar #bar-container .item:hover, #dev-bar #bar-container .item:focus { + background: rgba(27, 30, 36, 1); + cursor: pointer; + outline-offset: -3px; + } + + #dev-bar .item:first-of-type { + border-top-left-radius: 9999px; + border-bottom-left-radius: 9999px; + } + + #dev-bar .item:last-of-type { + border-top-right-radius: 9999px; + border-bottom-right-radius: 9999px; + } + #dev-bar #bar-container .item.active { + background: rgba(71, 78, 94, 1); + } + + #dev-bar #bar-container .item.active .notification { + border-color: rgba(71, 78, 94, 1); + } + + #dev-bar .item .icon { + position: relative; + max-width: 24px; + max-height: 24px; + user-select: none; + } + + #dev-bar .item svg { + width: 24px; + height: 24px; + display: block; + margin: auto; + } + + #dev-bar .item .notification { + display: none; + position: absolute; + top: -2px; + right: 0; + width: 8px; + height: 8px; + border-radius: 9999px; + border: 1px solid rgba(19, 21, 26, 1); + background: #B33E66; + } + + #dev-bar .item .notification[data-active] { + display: block; + } + + #dev-bar #bar-container { + height: 100%; + display: flex; + } + + #dev-bar .separator { + background: rgba(52, 56, 65, 1); + width: 1px; + } + + astro-overlay-plugin-canvas { + position: absolute; + top: 0; + left: 0; + } + + astro-overlay-plugin-canvas:not([data-active]) { + display: none; + } + + #minimize-button { + width: 32px; + height: 32px; + background: rgba(255, 255, 255, 0.75); + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + opacity: 0; + transition: opacity 0.2s ease-in-out; + pointer-events: auto; + border: 0; + color: white; + font-family: system-ui, sans-serif; + font-size: 1rem; + line-height: 1.2; + white-space: nowrap; + text-decoration: none; + padding: 0; + margin: 0; + } + + #minimize-button:hover, #minimize-button:focus { + cursor: pointer; + background: rgba(255, 255, 255, 0.90); + } + + #minimize-button svg { + width: 16px; + height: 16px; + } + + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; + } + </style> + + <div id="dev-overlay"> + <div id="dev-bar"> + <div id="bar-container"> + ${builtinPlugins.map((plugin) => this.getPluginTemplate(plugin)).join('')} + <div class="separator"></div> + ${customPlugins.map((plugin) => this.getPluginTemplate(plugin)).join('')} + </div> + </div> + <button id="minimize-button">${getIconElement('arrow-down')?.outerHTML}</button> + </div>`; + + this.devOverlay = this.shadowRoot.querySelector<HTMLDivElement>('#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<HTMLDivElement>('.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<HTMLDivElement>('#minimize-button'); + if (minimizeButton && this.devOverlay) { + minimizeButton.addEventListener('click', () => { + this.toggleOverlay(false); + this.toggleMinimizeButton(false); + }); + } + + const devBar = this.shadowRoot.querySelector<HTMLDivElement>('#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 `<button class="item" data-plugin-id="${plugin.id}"> + <div class="icon">${this.getPluginIcon(plugin.icon)}<div class="notification"></div></div> + <span class="sr-only">${plugin.name}</span> + </button>`; + } + + 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<HTMLDivElement>('#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<HTMLDivElement>('#bar-container'); + const devBar = this.shadowRoot.querySelector<HTMLDivElement>('#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 000000000..3629776ea --- /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 = ` + <style> + #buttons-container { + display: flex; + gap: 16px; + justify-content: center; + } + + #buttons-container astro-overlay-card { + flex: 1; + } + + footer { + display: flex; + justify-content: center; + gap: 24px; + } + + footer a { + color: rgba(145, 152, 173, 1); + } + + footer a:hover { + color: rgba(204, 206, 216, 1); + } + + #main-container { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + } + + p { + margin-top: 0; + } + </style> + + <div id="main-container"> + <div> + <p>Welcome to Astro!</p> + <div id="buttons-container"> + <astro-overlay-card icon="astro:logo" link="https://github.com/withastro/astro/issues/new/choose">Report an issue</astro-overlay-card> + <astro-overlay-card icon="astro:logo" link="https://docs.astro.build/en/getting-started/">View Astro Docs</astro-overlay-card> + </div> + </div> + <footer> + <a href="https://discord.gg/astro" target="_blank">Join the Astro Discord</a> + <a href="https://astro.build" target="_blank">Visit Astro.build</a> + </footer> + </div> + `; + + 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 000000000..bb94ead94 --- /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 = + '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 16"><path fill="#fff" d="M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z"/></svg>'; + +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 000000000..34bfd1f5a --- /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 000000000..123cab8f3 --- /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 = + '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M7.9 1.5v-.4a1.1 1.1 0 0 1 2.2 0v.4a1.1 1.1 0 1 1-2.2 0Zm-6.4 8.6a1.1 1.1 0 1 0 0-2.2h-.4a1.1 1.1 0 0 0 0 2.2h.4ZM12 3.7a1.1 1.1 0 0 0 1.4-.7l.4-1.1a1.1 1.1 0 0 0-2.1-.8l-.4 1.2a1.1 1.1 0 0 0 .7 1.4Zm-9.7 7.6-1.2.4a1.1 1.1 0 1 0 .8 2.1l1-.4a1.1 1.1 0 1 0-.6-2ZM20.8 17a1.9 1.9 0 0 1 0 2.6l-1.2 1.2a1.9 1.9 0 0 1-2.6 0l-4.3-4.2-1.6 3.6a1.9 1.9 0 0 1-1.7 1.2A1.9 1.9 0 0 1 7.5 20L2.7 5a1.9 1.9 0 0 1 2.4-2.4l15 5a1.9 1.9 0 0 1 .2 3.4l-3.7 1.6 4.2 4.3ZM19 18.3 14.6 14a1.9 1.9 0 0 1 .6-3l3.2-1.5L5.1 5.1l4.3 13.3 1.5-3.2a1.9 1.9 0 0 1 3-.6l4.4 4.4.7-.7Z"/></svg>'; + +export default { + id: 'astro:xray', + name: 'Xray', + icon: icon, + init(canvas) { + let islandsOverlays: { highlightElement: DevOverlayHighlight; island: HTMLElement }[] = []; + addIslandsOverlay(); + + function addIslandsOverlay() { + const islands = document.querySelectorAll<HTMLElement>('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: `<code>client:${islandClientDirective}</code>`, + }); + } + + // Add the props if we have any + if (Object.keys(islandProps).length > 0) { + tooltip.sections.push({ + title: 'Props', + content: `${Object.entries(islandProps) + .map((prop) => `<code>${prop[0]}=${getPropValue(prop[1] as any)}</code>`) + .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 000000000..debba9786 --- /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 = ` + <style> + a, button { + display: block; + padding: 40px 16px; + border-radius: 8px; + border: 1px solid rgba(35, 38, 45, 1); + color: #fff; + font-size: 16px; + font-weight: 600; + line-height: 19px; + text-decoration: none; + } + + a:hover, button:hover { + background: rgba(136, 58, 234, 0.33); + border: 1px solid rgba(113, 24, 226, 1) + } + + svg { + display: block; + margin: 0 auto; + } + + span { + margin-top: 8px; + display: block; + text-align: center; + } + </style> + + <${element}${this.link ? ` href="${this.link}" target="_blank"` : ``}> + ${this.icon ? this.getElementForIcon(this.icon) : ''} + <span><slot /></span> + </${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; + } + + 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 000000000..7d91535e0 --- /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 = ` + <style> + :host { + background: linear-gradient(180deg, rgba(224, 204, 250, 0.33) 0%, rgba(224, 204, 250, 0.0825) 100%); + border: 1px solid rgba(113, 24, 226, 1); + border-radius: 4px; + display: block; + width: 100%; + height: 100%; + position: absolute; + } + + .icon { + width: 24px; + height: 24px; + background: linear-gradient(0deg, #B33E66, #B33E66), linear-gradient(0deg, #351722, #351722); + border: 1px solid rgba(53, 23, 34, 1); + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: -15px; + right: -15px; + } + </style> + `; + } + + 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 000000000..a471249b3 --- /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<unknown>); + +export function isDefinedIcon(icon: Icon): icon is DefinedIcon { + return icon in icons; +} + +export function getIconElement( + name: keyof typeof icons | (string & NonNullable<unknown>) +): 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': `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 85 107"><path fill="#fff" d="M27.6 91.1c-4.8-4.4-6.3-13.7-4.2-20.4 3.5 4.2 8.3 5.6 13.3 6.3 7.7 1.2 15.3.8 22.5-2.8l2.5-1.4c.7 2 .9 3.9.6 5.9-.6 4.9-3 8.7-6.9 11.5-1.5 1.2-3.2 2.2-4.8 3.3-4.9 3.3-6.2 7.2-4.4 12.9l.2.6a13 13 0 0 1-5.7-5 13.8 13.8 0 0 1-2.2-7.4c0-1.3 0-2.7-.2-4-.5-3.1-2-4.6-4.8-4.7a5.5 5.5 0 0 0-5.7 4.6l-.2.6Z"/><path fill="url(#a)" d="M27.6 91.1c-4.8-4.4-6.3-13.7-4.2-20.4 3.5 4.2 8.3 5.6 13.3 6.3 7.7 1.2 15.3.8 22.5-2.8l2.5-1.4c.7 2 .9 3.9.6 5.9-.6 4.9-3 8.7-6.9 11.5-1.5 1.2-3.2 2.2-4.8 3.3-4.9 3.3-6.2 7.2-4.4 12.9l.2.6a13 13 0 0 1-5.7-5 13.8 13.8 0 0 1-2.2-7.4c0-1.3 0-2.7-.2-4-.5-3.1-2-4.6-4.8-4.7a5.5 5.5 0 0 0-5.7 4.6l-.2.6Z"/><path fill="#fff" d="M0 69.6s14.3-7 28.7-7l10.8-33.5c.4-1.6 1.6-2.7 3-2.7 1.2 0 2.4 1.1 2.8 2.7l10.9 33.5c17 0 28.6 7 28.6 7L60.5 3.2c-.7-2-2-3.2-3.5-3.2H27.8c-1.6 0-2.7 1.3-3.4 3.2L0 69.6Z"/><defs><linearGradient id="a" x1="22.5" x2="69.1" y1="107" y2="84.9" gradientUnits="userSpaceOnUse"><stop stop-color="#D83333"/><stop offset="1" stop-color="#F041FF"/></linearGradient></defs></svg>`, + warning: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#fff" d="M8 .40625c-1.5019 0-2.97007.445366-4.21886 1.27978C2.53236 2.52044 1.55905 3.70642.984293 5.094.40954 6.48157.259159 8.00842.552165 9.48147.845172 10.9545 1.56841 12.3076 2.63041 13.3696c1.06201 1.062 2.41508 1.7852 3.88813 2.0782 1.47304.293 2.99989.1427 4.38746-.4321 1.3876-.5747 2.5736-1.5481 3.408-2.7968.8344-1.2488 1.2798-2.717 1.2798-4.2189-.0023-2.0133-.8031-3.9435-2.2267-5.36713C11.9435 1.20925 10.0133.408483 8 .40625ZM8 13.9062c-1.16814 0-2.31006-.3463-3.28133-.9953-.97128-.649-1.7283-1.5715-2.17533-2.6507-.44703-1.0792-.56399-2.26675-.3361-3.41245.22789-1.1457.79041-2.1981 1.61641-3.0241.82601-.826 1.8784-1.38852 3.0241-1.61641 1.1457-.2279 2.33325-.11093 3.41245.3361 1.0793.44703 2.0017 1.20405 2.6507 2.17532.649.97128.9954 2.11319.9954 3.28134-.0017 1.56592-.6245 3.0672-1.7318 4.1745S9.56592 13.9046 8 13.9062Zm-.84375-5.62495V4.625c0-.22378.0889-.43839.24713-.59662.15824-.15824.37285-.24713.59662-.24713.22378 0 .43839.08889.59662.24713.15824.15823.24713.37284.24713.59662v3.65625c0 .22378-.08889.43839-.24713.59662C8.43839 9.03611 8.22378 9.125 8 9.125c-.22377 0-.43838-.08889-.59662-.24713-.15823-.15823-.24713-.37284-.24713-.59662ZM9.125 11.0938c0 .2225-.06598.44-.18959.625-.12362.185-.29932.3292-.50489.4143-.20556.0852-.43176.1074-.64999.064-.21823-.0434-.41869-.1505-.57602-.3079-.15734-.1573-.26448-.3577-.30789-.576-.04341-.2182-.02113-.4444.06402-.65.08515-.2055.22934-.3812.41435-.5049.185-.1236.40251-.18955.62501-.18955.29837 0 .58452.11855.7955.32955.21098.2109.3295.4971.3295.7955Z"/></svg>`, + 'arrow-down': + '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 14"><path fill="#13151A" d="m11.0306 8.53063-4.5 4.49997c-.06968.0699-.15247.1254-.24364.1633-.09116.0378-.1889.0573-.28761.0573-.09871 0-.19645-.0195-.28762-.0573-.09116-.0379-.17395-.0934-.24363-.1633L.968098 8.53063c-.140896-.1409-.220051-.332-.220051-.53125 0-.19926.079155-.39036.220051-.53125.140892-.1409.331992-.22006.531252-.22006.19926 0 .39035.07916.53125.22006l3.21937 3.21937V1.5c0-.19891.07902-.38968.21967-.53033C5.61029.829018 5.80106.75 5.99997.75c.19891 0 .38968.079018.53033.21967.14065.14065.21967.33142.21967.53033v9.1875l3.21938-3.22c.14085-.1409.33195-.22005.53125-.22005.1993 0 .3904.07915.5312.22005.1409.1409.2201.33199.2201.53125s-.0792.39035-.2201.53125l-.0012.00063Z"/></svg>', +} 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 000000000..63244ab6b --- /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<void>; + clickDescription?: string; +} + +export class DevOverlayTooltip extends HTMLElement { + sections: DevOverlayTooltipSection[] = []; + shadowRoot: ShadowRoot; + + constructor() { + super(); + this.shadowRoot = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot.innerHTML = ` + <style> + :host { + position: absolute; + display: none; + color: white; + background: linear-gradient(0deg, #310A65, #310A65), linear-gradient(0deg, #7118E2, #7118E2); + border: 1px solid rgba(113, 24, 226, 1); + border-radius: 4px; + padding: 0; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 14px; + margin: 0; + z-index: 9999999; + max-width: 45ch; + width: fit-content; + min-width: 27ch; + } + + :host([data-show="true"]) { + display: block; + } + + svg { + vertical-align: bottom; + margin-right: 4px; + } + + hr { + border: 1px solid rgba(136, 58, 234, 0.33); + padding: 0; + margin: 0; + } + + section { + padding: 8px; + } + + .modal-title { + display: flex; + justify-content: space-between; + align-items: center; + } + + .modal-main-title { + font-weight: bold; + } + + .modal-title + div { + margin-top: 8px; + } + + .modal-cta { + display: block; + font-weight: bold; + font-size: 0.9em; + } + + .clickable-section { + background: rgba(113, 24, 226, 1); + padding: 8px; + border: 0; + color: white; + font-family: system-ui, sans-serif; + text-align: left; + line-height: 1.2; + white-space: nowrap; + text-decoration: none; + margin: 0; + width: 100%; + } + + .clickable-section:hover { + cursor: pointer; + } + + code { + background: rgba(136, 58, 234, 0.33); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + border-radius: 2px; + font-size: 14px; + padding: 2px; + } + `; + + const fragment = new DocumentFragment(); + this.sections.forEach((section, index) => { + const sectionElement = section.clickAction + ? document.createElement('button') + : document.createElement('section'); + + if (section.clickAction) { + sectionElement.classList.add('clickable-section'); + sectionElement.addEventListener('click', async () => { + await section.clickAction!(); + }); + } + + sectionElement.innerHTML = ` + ${ + section.title + ? `<div class="modal-title"><span class="modal-main-title"> + ${section.icon ? this.getElementForIcon(section.icon) : ''}${section.title}</span>${ + section.inlineTitle ?? '' + }</div>` + : '' + } + ${section.content ? `<div>${section.content}</div>` : ''} + ${section.clickDescription ? `<span class="modal-cta">${section.clickDescription}</span>` : ''} + `; + fragment.append(sectionElement); + + if (index < this.sections.length - 1) { + fragment.append(document.createElement('hr')); + } + }); + + this.shadowRoot.append(fragment); + } + + getElementForIcon(icon: Icon | (string & NonNullable<unknown>)) { + 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('width', '16px'); + iconElement?.style.setProperty('height', '16px'); + + return iconElement?.outerHTML ?? ''; + } +} 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 new file mode 100644 index 000000000..cc483b227 --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/ui-library/window.ts @@ -0,0 +1,76 @@ +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() { + this.shadowRoot.innerHTML = ` + <style> + :host { + display: flex; + flex-direction: column; + background: linear-gradient(0deg, #13151A, #13151A), linear-gradient(0deg, #343841, #343841); + border: 1px solid rgba(52, 56, 65, 1); + width: 640px; + height: 480px; + border-radius: 12px; + padding: 24px; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + color: rgba(204, 206, 216, 1); + position: fixed; + z-index: 9999999999; + top: 55%; + left: 50%; + transform: translate(-50%, -50%); + } + + h1 { + margin: 0; + font-weight: 600; + color: #fff; + } + + h1 svg { + vertical-align: text-bottom; + margin-right: 8px; + } + + hr { + border: 1px solid rgba(27, 30, 36, 1); + margin: 1em 0; + } + </style> + + <h1>${this.windowIcon ? this.getElementForIcon(this.windowIcon) : ''}${this.windowTitle ?? ''}</h1> + <hr /> + <slot /> + `; + } + + 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 069c2ffe8..163fcdd04 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 000000000..5c3aabe5a --- /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(',')}]; + }; + `; + } + }, + }; +} |