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:
parent
4740d761ae
commit
e99586787b
20 changed files with 1341 additions and 1 deletions
21
.changeset/large-stingrays-fry.md
Normal file
21
.changeset/large-stingrays-fry.md
Normal 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.
|
|
@ -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: {
|
||||
|
|
1
.github/scripts/bundle-size.mjs
vendored
1
.github/scripts/bundle-size.mjs
vendored
|
@ -68,6 +68,7 @@ async function bundle(files) {
|
|||
sourcemap: false,
|
||||
target: ['es2018'],
|
||||
outdir: 'out',
|
||||
external: ['astro:*'],
|
||||
metafile: true,
|
||||
})
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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.`
|
||||
|
|
|
@ -98,6 +98,7 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
|
|||
scripts: [],
|
||||
clientDirectives: getDefaultClientDirectives(),
|
||||
watchFiles: [],
|
||||
devOverlayPlugins: [],
|
||||
timer: new AstroTimer(),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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(
|
||||
|
|
505
packages/astro/src/runtime/client/dev-overlay/overlay.ts
Normal file
505
packages/astro/src/runtime/client/dev-overlay/overlay.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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';
|
||||
});
|
||||
});
|
||||
}
|
103
packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts
Normal file
103
packages/astro/src/runtime/client/dev-overlay/plugins/xray.ts
Normal 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;
|
|
@ -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 ?? '';
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 ?? '';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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(',')}];
|
||||
};
|
||||
`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
Loading…
Reference in a new issue