diff --git a/examples/minimal/src/components/List.astro b/examples/minimal/src/components/List.astro new file mode 100644 index 000000000..4ab9cdf76 --- /dev/null +++ b/examples/minimal/src/components/List.astro @@ -0,0 +1,6 @@ + + diff --git a/examples/minimal/src/components/Title.astro b/examples/minimal/src/components/Title.astro new file mode 100644 index 000000000..6baac462e --- /dev/null +++ b/examples/minimal/src/components/Title.astro @@ -0,0 +1,7 @@ +

Woah...

+ + diff --git a/examples/minimal/src/pages/index.astro b/examples/minimal/src/pages/index.astro index 554de1a39..0c9499880 100644 --- a/examples/minimal/src/pages/index.astro +++ b/examples/minimal/src/pages/index.astro @@ -1,4 +1,6 @@ --- +import Title from "../components/Title.astro"; +import List from "../components/List.astro"; --- @@ -9,6 +11,7 @@ Astro -

Astro

+ + <List /> </body> </html> diff --git a/packages/astro/package.json b/packages/astro/package.json index 9359f8c3c..90b897cd4 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -117,6 +117,7 @@ "kleur": "^4.1.4", "magic-string": "^0.25.9", "mime": "^3.0.0", + "open-editor": "^4.0.0", "ora": "^6.1.0", "path-browserify": "^1.0.1", "path-to-regexp": "^6.2.1", diff --git a/packages/astro/src/runtime/client/hmr.ts b/packages/astro/src/runtime/client/hmr.ts index f3a3074f3..e9f0a4fdc 100644 --- a/packages/astro/src/runtime/client/hmr.ts +++ b/packages/astro/src/runtime/client/hmr.ts @@ -1,12 +1,18 @@ /// <reference types="vite/client" /> +const attrs = { + file: 'data-astro-source-file', + loc: 'data-astro-source-loc', +} if (import.meta.hot) { + initClickToComponent(); // Vite injects `<style type="text/css">` for ESM imports of styles // but Astro also SSRs with `<style>` blocks. This MutationObserver // removes any duplicates as soon as they are hydrated client-side. const injectedStyles = getInjectedStyles(); const mo = new MutationObserver((records) => { for (const record of records) { + cleanNodes(record.addedNodes); for (const node of record.addedNodes) { if (isViteInjectedStyle(node)) { injectedStyles.get(node.innerHTML.trim())?.remove(); @@ -44,3 +50,120 @@ function isStyle(node: Node): node is HTMLStyleElement { function isViteInjectedStyle(node: Node): node is HTMLStyleElement { return isStyle(node) && node.getAttribute('type') === 'text/css'; } + +function isElement(node: EventTarget | null): node is HTMLElement { + return !!(node as any)?.tagName; +} + +function getEditorInfo(el: HTMLElement) { + const { file, loc } = (el as any).__astro; + if (!file) return; + return { file, loc } +} + +function initClickToComponent() { + cleanNodes(document.querySelectorAll('[data-astro-source-file]')); + const style = document.createElement('style') + style.innerHTML = `[data-astro-edit] { + outline: 0; +} +astro-edit-overlay { + transition: opacity 300ms linear ease-out; + --padding-inline: 4px; + --padding-block: 2px; + position: fixed; + top: 0; + left: 0; + pointer-events: none; + transform-origin: top left; + border: 1px solid #863BE4; + width: calc(var(--width, 0) + (var(--padding-inline) * 2)); + height: calc(var(--height, 0) + (var(--padding-block) * 2)); + transform: translate(calc(var(--x, 0) - var(--padding-inline)), calc(var(--y, 0) - var(--padding-block))); + border-radius: 2px; +} +` + document.head.appendChild(style); + const initialBody = document.body.innerHTML; + let newBody = document.body.innerHTML; + let altKey = false; + let active = false; + let target: HTMLElement | null = null; + customElements.define('astro-edit-overlay', class AstroEditOverlay extends HTMLElement {}) + const overlay = document.createElement('astro-edit-overlay'); + document.body.appendChild(overlay); + window.addEventListener('keydown', (event) => { + if (!event.altKey) return; + altKey = true; + updateOverlay() + }) + window.addEventListener('keyup', () => { + altKey = false; + updateOverlay(); + }) + window.addEventListener('mousemove', (event) => { + if (!isElement(event.target)) return; + if (event.target === document.documentElement || event.target === document.body) return; + if (target !== event.target) { + target = event.target + updateOverlay(); + } + }) + function updateOverlay() { + if (!target) { + return; + } + if (!altKey) { + overlay.style.setProperty('opacity', '0'); + return; + } + overlay.style.removeProperty('opacity'); + const range = document.createRange(); + range.selectNodeContents(target!) + let rect = range.getClientRects()[0] + if (!rect) return; + overlay.style.setProperty('--x', `${rect.left - window.scrollX}px`); + overlay.style.setProperty('--y', `${rect.top - window.scrollY}px`); + overlay.style.setProperty('--width', `${rect.width}px`); + overlay.style.setProperty('--height', `${rect.height}px`); + } + window.addEventListener('click', (event) => { + if (!event.altKey) return; + const el = event.target; + if (!isElement(el)) return; + const detail = getEditorInfo(el); + if (detail) { + el.contentEditable = 'plaintext-only'; + el.setAttribute('data-astro-edit', ''); + const initialText = el.innerHTML; + let newText = initialText; + target = event.target as HTMLElement; + el.focus(); + el.addEventListener('blur', () => { + el.removeAttribute('contenteditable'); + el.removeAttribute('data-astro-edit'); + altKey = false; + target = null; + if (initialText.trim() === newText.trim()) return; + newBody = document.body.innerHTML; + import.meta.hot.send('astro:edit', { ...detail, replace: [initialText.trim(), newText.trim()] }); + }) + el.addEventListener('input', (event) => { + newText = el.innerHTML; + updateOverlay(); + }) + } + }); +} + +function cleanNodes(nodes: NodeList) { + for (const node of nodes) { + if (isElement(node)) { + (node as any).__astro = {}; + for (const [key, attr] of Object.entries(attrs)) { + (node as any).__astro[key] = node.getAttribute(attr); + node.removeAttribute(attr); + } + } + } +} diff --git a/packages/astro/src/vite-plugin-astro/compile.ts b/packages/astro/src/vite-plugin-astro/compile.ts index 02c3753c4..2f0b46f96 100644 --- a/packages/astro/src/vite-plugin-astro/compile.ts +++ b/packages/astro/src/vite-plugin-astro/compile.ts @@ -83,6 +83,7 @@ async function compile({ internalURL: `/@fs${prependForwardSlash( viteID(new URL('../runtime/server/index.js', import.meta.url)) )}`, + annotateSourceFile: true, // TODO: baseline flag experimentalStaticExtraction: true, preprocessStyle: async (value: string, attrs: Record<string, string>) => { diff --git a/packages/astro/src/vite-plugin-astro/index.ts b/packages/astro/src/vite-plugin-astro/index.ts index 393128803..f9d2e850a 100644 --- a/packages/astro/src/vite-plugin-astro/index.ts +++ b/packages/astro/src/vite-plugin-astro/index.ts @@ -7,10 +7,11 @@ import type { PluginMetadata as AstroPluginMetadata } from './types'; import ancestor from 'common-ancestor-path'; import esbuild from 'esbuild'; import slash from 'slash'; +import fs from 'node:fs/promises'; import { fileURLToPath } from 'url'; import { isRelativePath, startsWithForwardSlash } from '../core/path.js'; import { getFileInfo } from '../vite-plugin-utils/index.js'; -import { cachedCompilation, CompileProps, getCachedSource } from './compile.js'; +import { cachedCompilation, CompileProps, getCachedSource, invalidateCompilation } from './compile.js'; import { handleHotUpdate, trackCSSDependencies } from './hmr.js'; import { parseAstroRequest, ParsedRequestResult } from './query.js'; import { getViteTransform, TransformHook } from './styles.js'; @@ -44,7 +45,7 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu // Variables for determing if an id starts with /src... const srcRootWeb = config.srcDir.pathname.slice(config.root.pathname.length - 1); const isBrowserPath = (path: string) => path.startsWith(srcRootWeb); - + const edits: Record<string, string> = {}; function resolveRelativeFromAstroParent(id: string, parsedFrom: ParsedRequestResult): string { const filename = normalizeFilename(parsedFrom.filename); const resolvedURL = new URL(id, `file://${filename}`); @@ -64,6 +65,19 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu }, configureServer(server) { viteDevServer = server; + server.ws.on('astro:edit', async ({ file: fileURL, loc, replace: [from, to] }) => { + if (fileURL.startsWith('/@fs')) { + fileURL = fileURL.replace('/@fs', 'file://') + } + const file = fileURLToPath(new URL(fileURL)); + const [line, column] = loc.split(':').map((v: string) => Number(v)); + const text = edits[file] ?? await fs.readFile(file, 'utf-8'); + const start = text.split('\n').slice(0, line - 1).join('\n'); + const end = text.split('\n').slice(line - 1).join('\n'); + const newText = end.replace(from, to); + const output = `${start.trim() ? start + '\n' : ''}${newText}`; + edits[file] = output; + }) }, // note: don’t claim .astro files with resolveId() — it prevents Vite from transpiling the final JS (import.meta.glob, etc.) async resolveId(id, from, opts) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de355297a..0efc39304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,7 +236,10 @@ importers: examples/minimal: specifiers: + '@astrojs/devtools': ^0.0.1 astro: ^1.0.6 + dependencies: + '@astrojs/devtools': link:../../packages/integrations/devtools devDependencies: astro: link:../../packages/astro @@ -438,6 +441,7 @@ importers: magic-string: ^0.25.9 mime: ^3.0.0 mocha: ^9.2.2 + open-editor: ^4.0.0 ora: ^6.1.0 path-browserify: ^1.0.1 path-to-regexp: ^6.2.1 @@ -496,6 +500,7 @@ importers: kleur: 4.1.5 magic-string: 0.25.9 mime: 3.0.0 + open-editor: 4.0.0 ora: 6.1.2 path-browserify: 1.0.1 path-to-regexp: 6.2.1 @@ -2167,6 +2172,14 @@ importers: '@astrojs/deno': link:../../.. astro: link:../../../../../astro + packages/integrations/devtools: + specifiers: + astro: workspace:* + astro-scripts: workspace:* + devDependencies: + astro: link:../../astro + astro-scripts: link:../../../scripts + packages/integrations/image: specifiers: '@types/etag': ^1.8.1 @@ -10269,7 +10282,6 @@ packages: /define-lazy-prop/2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} - dev: true /define-properties/1.1.4: resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} @@ -10501,6 +10513,11 @@ packages: engines: {node: '>=0.12'} dev: true + /env-editor/1.1.0: + resolution: {integrity: sha512-7AXskzN6T7Q9TFcKAGJprUbpQa4i1VsAetO9rdBqbGMGlragTziBgWt4pVYJMBWHQlLoX0buy6WFikzPH4Qjpw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /eol/0.9.1: resolution: {integrity: sha512-Ds/TEoZjwggRoz/Q2O7SE3i4Jm66mqTDfmdHdq/7DKVk3bro9Q8h6WdXKdPqFLMoqxrDK5SVRzHVPOS6uuGtrg==} dev: false @@ -11215,7 +11232,6 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 strip-final-newline: 2.0.0 - dev: true /execa/6.1.0: resolution: {integrity: sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==} @@ -11983,7 +11999,6 @@ packages: /human-signals/2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} - dev: true /human-signals/3.0.1: resolution: {integrity: sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==} @@ -12299,7 +12314,6 @@ packages: /is-stream/2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - dev: true /is-stream/3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} @@ -12518,6 +12532,13 @@ packages: resolution: {integrity: sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==} engines: {node: '>=10'} + /line-column-path/3.0.0: + resolution: {integrity: sha512-Atocnm7Wr9nuvAn97yEPQa3pcQI5eLQGBz+m6iTb+CVw+IOzYB9MrYK7jI7BfC9ISnT4Fu0eiwhAScV//rp4Hw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + type-fest: 2.18.0 + dev: false + /lines-and-columns/1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: true @@ -13706,7 +13727,6 @@ packages: engines: {node: '>=8'} dependencies: path-key: 3.1.1 - dev: true /npm-run-path/5.1.0: resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} @@ -13787,6 +13807,16 @@ packages: dependencies: mimic-fn: 4.0.0 + /open-editor/4.0.0: + resolution: {integrity: sha512-5mKZ98iFdkivozt5XTCOspoKbL3wtYu6oOoVxfWQ0qUX9NYsK8pdkHE7VUHXr+CwyC3nf6mV0S5FPsMS65innw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + env-editor: 1.1.0 + execa: 5.1.1 + line-column-path: 3.0.0 + open: 8.4.0 + dev: false + /open/8.4.0: resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} engines: {node: '>=12'} @@ -13794,7 +13824,6 @@ packages: define-lazy-prop: 2.0.0 is-docker: 2.2.1 is-wsl: 2.2.0 - dev: true /optionator/0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} @@ -15720,7 +15749,6 @@ packages: /strip-final-newline/2.0.0: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} - dev: true /strip-final-newline/3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}