Allow defining Astro components in Vite plugins (#3889)

* Allow defining Astro components in Vite plugins

* Adds a changeset

* Move non-main compilation into load

* Use the cachedCompilation in the markdown plugin

* Fix HMR test

* Simplify getNormalizedID

* Use a windows-friendly virtual module id for the test
This commit is contained in:
Matthew Phillips 2022-07-11 16:13:21 -04:00 committed by GitHub
parent 1896931931
commit 5f4ecbad1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 194 additions and 40 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Allow defining Astro components in Vite plugins

View file

@ -11,7 +11,10 @@ import { viteID } from '../core/util.js';
import { transformWithVite } from './styles.js'; import { transformWithVite } from './styles.js';
type CompilationCache = Map<string, CompileResult>; type CompilationCache = Map<string, CompileResult>;
type CompileResult = TransformResult & { rawCSSDeps: Set<string> }; type CompileResult = TransformResult & {
rawCSSDeps: Set<string>;
source: string;
};
/** /**
* Note: this is currently needed because Astro is directly using a Vite internal CSS transform. This gives us * Note: this is currently needed because Astro is directly using a Vite internal CSS transform. This gives us
@ -44,6 +47,16 @@ export interface CompileProps {
pluginContext: PluginContext; pluginContext: PluginContext;
} }
function getNormalizedID(filename: string): string {
try {
const filenameURL = new URL(`file://${filename}`);
return fileURLToPath(filenameURL);
} catch(err) {
// Not a real file, so just use the provided filename as the normalized id
return filename;
}
}
async function compile({ async function compile({
config, config,
filename, filename,
@ -53,9 +66,7 @@ async function compile({
viteTransform, viteTransform,
pluginContext, pluginContext,
}: CompileProps): Promise<CompileResult> { }: CompileProps): Promise<CompileResult> {
const filenameURL = new URL(`file://${filename}`); const normalizedID = getNormalizedID(filename);
const normalizedID = fileURLToPath(filenameURL);
let rawCSSDeps = new Set<string>(); let rawCSSDeps = new Set<string>();
let cssTransformError: Error | undefined; let cssTransformError: Error | undefined;
@ -141,6 +152,9 @@ async function compile({
rawCSSDeps: { rawCSSDeps: {
value: rawCSSDeps, value: rawCSSDeps,
}, },
source: {
value: source,
},
}); });
return compileResult; return compileResult;
@ -150,6 +164,13 @@ export function isCached(config: AstroConfig, filename: string) {
return configCache.has(config) && configCache.get(config)!.has(filename); return configCache.has(config) && configCache.get(config)!.has(filename);
} }
export function getCachedSource(config: AstroConfig, filename: string): string | null {
if(!isCached(config, filename)) return null;
let src = configCache.get(config)!.get(filename);
if(!src) return null;
return src.source;
}
export function invalidateCompilation(config: AstroConfig, filename: string) { export function invalidateCompilation(config: AstroConfig, filename: string) {
if (configCache.has(config)) { if (configCache.has(config)) {
const cache = configCache.get(config)!; const cache = configCache.get(config)!;

View file

@ -13,7 +13,7 @@ import { isRelativePath, startsWithForwardSlash } from '../core/path.js';
import { resolvePages } from '../core/util.js'; import { resolvePages } from '../core/util.js';
import { PAGE_SCRIPT_ID, PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; import { PAGE_SCRIPT_ID, PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
import { getFileInfo } from '../vite-plugin-utils/index.js'; import { getFileInfo } from '../vite-plugin-utils/index.js';
import { cachedCompilation, CompileProps } from './compile.js'; import { cachedCompilation, CompileProps, getCachedSource } from './compile.js';
import { handleHotUpdate, trackCSSDependencies } from './hmr.js'; import { handleHotUpdate, trackCSSDependencies } from './hmr.js';
import { parseAstroRequest, ParsedRequestResult } from './query.js'; import { parseAstroRequest, ParsedRequestResult } from './query.js';
import { getViteTransform, TransformHook } from './styles.js'; import { getViteTransform, TransformHook } from './styles.js';
@ -96,24 +96,25 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
return id; return id;
} }
}, },
async load(this: PluginContext, id, opts) { async load(id, opts) {
const parsedId = parseAstroRequest(id); const parsedId = parseAstroRequest(id);
const query = parsedId.query; const query = parsedId.query;
if (!id.endsWith('.astro') && !query.astro) { if (!query.astro) {
return null; return null;
} }
// if we still get a relative path here, vite couldn't resolve the import let filename = parsedId.filename;
if (isRelativePath(parsedId.filename)) { // For CSS / hoisted scripts we need to load the source ourselves.
// It should be in the compilation cache at this point.
let raw = await this.resolve(filename, undefined);
if(!raw) {
return null; return null;
} }
const filename = normalizeFilename(parsedId.filename); let source = getCachedSource(config, raw.id);
const fileUrl = new URL(`file://${filename}`); if(!source) {
let source = await fs.promises.readFile(fileUrl, 'utf-8'); return null;
const isPage = fileUrl.pathname.startsWith(resolvePages(config).pathname);
if (isPage && config._ctx.scripts.some((s) => s.stage === 'page')) {
source += `\n<script src="${PAGE_SCRIPT_ID}" />`;
} }
const compileProps: CompileProps = { const compileProps: CompileProps = {
config, config,
filename, filename,
@ -123,8 +124,9 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
viteTransform, viteTransform,
pluginContext: this, pluginContext: this,
}; };
if (query.astro) {
if (query.type === 'style') { switch(query.type) {
case 'style': {
if (typeof query.index === 'undefined') { if (typeof query.index === 'undefined') {
throw new Error(`Requests for Astro CSS must include an index.`); throw new Error(`Requests for Astro CSS must include an index.`);
} }
@ -144,7 +146,8 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
return { return {
code, code,
}; };
} else if (query.type === 'script') { }
case 'script': {
if (typeof query.index === 'undefined') { if (typeof query.index === 'undefined') {
throw new Error(`Requests for hoisted scripts must include an index`); throw new Error(`Requests for hoisted scripts must include an index`);
} }
@ -185,7 +188,39 @@ export default function astro({ config, logging }: AstroPluginOptions): vite.Plu
}, },
}; };
} }
default: return null;
} }
},
async transform(this: PluginContext, source, id, opts) {
const parsedId = parseAstroRequest(id);
const query = parsedId.query;
if (!id.endsWith('.astro') || query.astro) {
return source;
}
// if we still get a relative path here, vite couldn't resolve the import
if (isRelativePath(parsedId.filename)) {
return source;
}
const filename = normalizeFilename(parsedId.filename);
let isPage = false;
try {
const fileUrl = new URL(`file://${filename}`);
isPage = fileUrl.pathname.startsWith(resolvePages(config).pathname);
} catch {}
if (isPage && config._ctx.scripts.some((s) => s.stage === 'page')) {
source += `\n<script src="${PAGE_SCRIPT_ID}" />`;
}
const compileProps: CompileProps = {
config,
filename,
moduleId: id,
source,
ssr: Boolean(opts?.ssr),
viteTransform,
pluginContext: this,
};
try { try {
const transformResult = await cachedCompilation(compileProps); const transformResult = await cachedCompilation(compileProps);

View file

@ -12,6 +12,8 @@ import { collectErrorMetadata } from '../core/errors.js';
import { prependForwardSlash } from '../core/path.js'; import { prependForwardSlash } from '../core/path.js';
import { resolvePages, viteID } from '../core/util.js'; import { resolvePages, viteID } from '../core/util.js';
import type { PluginMetadata as AstroPluginMetadata } from '../vite-plugin-astro/types'; import type { PluginMetadata as AstroPluginMetadata } from '../vite-plugin-astro/types';
import { cachedCompilation, CompileProps } from '../vite-plugin-astro/compile.js';
import { getViteTransform, TransformHook } from '../vite-plugin-astro/styles.js';
import { PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; import { PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
import { getFileInfo } from '../vite-plugin-utils/index.js'; import { getFileInfo } from '../vite-plugin-utils/index.js';
@ -61,9 +63,14 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
return false; return false;
} }
let viteTransform: TransformHook;
return { return {
name: 'astro:markdown', name: 'astro:markdown',
enforce: 'pre', enforce: 'pre',
configResolved(_resolvedConfig) {
viteTransform = getViteTransform(_resolvedConfig);
},
async resolveId(id, importer, options) { async resolveId(id, importer, options) {
// Resolve any .md files with the `?content` cache buster. This should only come from // Resolve any .md files with the `?content` cache buster. This should only come from
// an already-resolved JS module wrapper. Needed to prevent infinite loops in Vite. // an already-resolved JS module wrapper. Needed to prevent infinite loops in Vite.
@ -85,7 +92,7 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
// In all other cases, we do nothing and rely on normal Vite resolution. // In all other cases, we do nothing and rely on normal Vite resolution.
return undefined; return undefined;
}, },
async load(id) { async load(id, opts) {
// A markdown file has been imported via ESM! // A markdown file has been imported via ESM!
// Return the file's JS representation, including all Markdown // Return the file's JS representation, including all Markdown
// frontmatter and a deferred `import() of the compiled markdown content. // frontmatter and a deferred `import() of the compiled markdown content.
@ -174,21 +181,17 @@ ${setup}`.trim();
} }
// Transform from `.astro` to valid `.ts` // Transform from `.astro` to valid `.ts`
let transformResult = await transform(astroResult, { const compileProps: CompileProps = {
pathname: '/@fs' + prependForwardSlash(fileUrl.pathname), config,
projectRoot: config.root.toString(), filename,
site: config.site moduleId: id,
? new URL(config.base, config.site).toString() source: astroResult,
: `http://localhost:${config.server.port}/`, ssr: Boolean(opts?.ssr),
sourcefile: id, viteTransform,
sourcemap: 'inline', pluginContext: this,
// TODO: baseline flag };
experimentalStaticExtraction: true,
internalURL: `/@fs${prependForwardSlash(
viteID(new URL('../runtime/server/index.js', import.meta.url))
)}`,
});
let transformResult = await cachedCompilation(compileProps)
let { code: tsResult } = transformResult; let { code: tsResult } = transformResult;
tsResult = `\nexport const metadata = ${JSON.stringify(metadata)}; tsResult = `\nexport const metadata = ${JSON.stringify(metadata)};

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import myPlugin from './src/plugin/my-plugin.mjs';
// https://astro.build/config
export default defineConfig({
vite: {
plugins: [myPlugin()]
}
});

View file

@ -0,0 +1,8 @@
{
"name": "@test/virtual-astro-file",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,10 @@
---
import Something from '@my-plugin/virtual.astro';
---
<html>
<head><title>Testing</title></head>
<body>
<Something />
</body>
</html>

View file

@ -0,0 +1,27 @@
export default function myPlugin() {
const pluginId = `@my-plugin/virtual.astro`;
return {
enforce: 'pre',
name: 'virtual-astro-plugin',
resolveId(id) {
if (id === pluginId) return id;
},
load(id) {
if (id === pluginId) {
return `---
const works = true;
---
<h1 id="something">This is a virtual module id</h1>
<h2 id="works">{works}</h2>
<style>
h1 {
color: green;
}
</style>
`;
}
},
};
}

View file

@ -22,6 +22,9 @@ describe('HMR - CSS', () => {
}); });
it('Timestamp URL used by Vite gets the right mime type', async () => { it('Timestamp URL used by Vite gets the right mime type', async () => {
// Index page is always loaded first by the browser
await fixture.fetch('/');
// Now we can simulate what happens in the browser
let res = await fixture.fetch( let res = await fixture.fetch(
'/src/pages/index.astro?astro=&type=style&index=0&lang.css=&t=1653657441095' '/src/pages/index.astro?astro=&type=style&index=0&lang.css=&t=1653657441095'
); );

View file

@ -0,0 +1,27 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';
describe('Loading virtual Astro files', () => {
let fixture;
before(async () => {
fixture = await loadFixture({ root: './fixtures/virtual-astro-file/' });
await fixture.build();
});
it('renders the component', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
expect($('#something')).to.have.a.lengthOf(1);
expect($('#works').text()).to.equal('true');
});
it('builds component CSS', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const href = $('link').attr('href');
const css = await fixture.readFile(href);
expect(css).to.match(/green/, 'css bundled from virtual astro module');
});
});

View file

@ -1802,6 +1802,12 @@ importers:
postcss: 8.4.14 postcss: 8.4.14
tailwindcss: 3.1.5 tailwindcss: 3.1.5
packages/astro/test/fixtures/virtual-astro-file:
specifiers:
astro: workspace:*
dependencies:
astro: link:../../..
packages/astro/test/fixtures/vue-component: packages/astro/test/fixtures/vue-component:
specifiers: specifiers:
'@astrojs/vue': workspace:* '@astrojs/vue': workspace:*