0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: Add a dev overlay (#8757)

* feat: initial commit for dev overlay

* fix: lockfile

* fix: build

* chore: get ci in a better state

* feat: client-server communication

* fix: better position for xray

* refactor: move icons to separate files

* refactor: cleanup components

* feat: home screen

* refactor: rename icon

* feat: flag the feature

* fix: cleanup

* fix: lockfile

* feat: minimize button

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* refactor: cleanup

* feat: add ability to go to component for hydrated components

* refactor: consistent logic between audit and xray

* chore: changeset

* Apply suggestions from code review

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* fix: unchonky the SVGs

* fix: button a11y

* refactor: move common highlight utilities to a dedicated file

* fix: allow tabbing on highlights

* fix: allow tooltip clickable sections to be tabbed to

* feat: allow using defined icons as plugin icons

* refactor: remove unnecessary resolve

* Update .changeset/large-stingrays-fry.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/large-stingrays-fry.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* nit: use append

* style: small tweaks to minimize button styling

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
Erika 2023-10-25 17:40:37 +02:00 committed by GitHub
parent 4740d761ae
commit e99586787b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1341 additions and 1 deletions

View file

@ -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.

View file

@ -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: {

View file

@ -68,6 +68,7 @@ async function bundle(files) {
sourcemap: false,
target: ['es2018'],
outdir: 'out',
external: ['astro:*'],
metafile: true,
})

View file

@ -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;
};
};

View file

@ -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.`

View file

@ -98,6 +98,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
scripts: [],
clientDirectives: getDefaultClientDirectives(),
watchFiles: [],
devOverlayPlugins: [],
timer: new AstroTimer(),
};
}

View file

@ -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),

View file

@ -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(

View file

@ -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);
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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';
});
});
}

View file

@ -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;

View file

@ -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 ?? '';
}
}

View file

@ -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);
}
}
}
}

View file

@ -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;

View file

@ -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 ?? '';
}
}

View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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(',')}];
};
`;
}
},
};
}