Skip to content

Commit

Permalink
Improve third-party Astro package support (#4623)
Browse files Browse the repository at this point in the history
  • Loading branch information
delucis authored Sep 6, 2022
1 parent 965a493 commit eb1862b
Show file tree
Hide file tree
Showing 7 changed files with 267 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-avocados-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Improve third-party Astro package support
140 changes: 116 additions & 24 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { AstroConfig } from '../@types/astro';
import type { LogOptions } from './logger/core';

import fs from 'fs';
import { createRequire } from 'module';
import path from 'path';
import { fileURLToPath } from 'url';
import * as vite from 'vite';
import astroPostprocessVitePlugin from '../vite-plugin-astro-postprocess/index.js';
Expand Down Expand Up @@ -174,34 +176,115 @@ function sortPlugins(pluginOptions: vite.PluginOption[]) {
// Scans `projectRoot` for third-party Astro packages that could export an `.astro` file
// `.astro` files need to be built by Vite, so these should use `noExternal`
async function getAstroPackages({ root }: AstroConfig): Promise<string[]> {
const pkgUrl = new URL('./package.json', root);
const pkgPath = fileURLToPath(pkgUrl);
if (!fs.existsSync(pkgPath)) return [];
const { astroPackages } = new DependencyWalker(root);
return astroPackages;
}

const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
/**
* Recursively walk a project’s dependency tree trying to find Astro packages.
* - If the current node is an Astro package, we continue walking its child dependencies.
* - If the current node is not an Astro package, we bail out of walking that branch.
* This assumes it is unlikely for Astro packages to be dependencies of packages that aren’t
* themselves also Astro packages.
*/
class DependencyWalker {
private readonly require: NodeRequire;
private readonly astroDeps = new Set<string>();
private readonly nonAstroDeps = new Set<string>();

const deps = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
constructor(root: URL) {
const pkgUrl = new URL('./package.json', root);
this.require = createRequire(pkgUrl);
const pkgPath = fileURLToPath(pkgUrl);
if (!fs.existsSync(pkgPath)) return;

return deps.filter((dep) => {
// Attempt: package is common and not Astro. ❌ Skip these for perf
if (isCommonNotAstro(dep)) return false;
// Attempt: package is named `astro-something`. ✅ Likely a community package
if (/^astro\-/.test(dep)) return true;
const depPkgUrl = new URL(`./node_modules/${dep}/package.json`, root);
const depPkgPath = fileURLToPath(depPkgUrl);
if (!fs.existsSync(depPkgPath)) return false;
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const deps = [
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
];

const {
dependencies = {},
peerDependencies = {},
keywords = [],
} = JSON.parse(fs.readFileSync(depPkgPath, 'utf-8'));
// Attempt: package relies on `astro`. ✅ Definitely an Astro package
if (peerDependencies.astro || dependencies.astro) return true;
// Attempt: package is tagged with `astro` or `astro-component`. ✅ Likely a community package
if (keywords.includes('astro') || keywords.includes('astro-component')) return true;
return false;
});
this.scanDependencies(deps);
}

/** The dependencies we determined were likely to include `.astro` files. */
public get astroPackages(): string[] {
return Array.from(this.astroDeps);
}

private seen(dep: string): boolean {
return this.astroDeps.has(dep) || this.nonAstroDeps.has(dep);
}

/** Try to load a directory’s `package.json` file from the filesystem. */
private readPkgJSON(dir: string): PkgJSON | void {
try {
const filePath = path.join(dir, 'package.json');
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch (e) {}
}

/** Try to resolve a dependency’s `package.json` even if not a package export. */
private resolvePkgJSON(dep: string): PkgJSON | void {
try {
const pkgJson: PkgJSON = this.require(dep + '/package.json');
return pkgJson;
} catch (e) {
// Most likely error is that the dependency doesn’t include `package.json` in its package `exports`.
try {
// Walk up from default export until we find `package.json` with name === dep.
let dir = path.dirname(this.require.resolve(dep));
while (dir) {
const pkgJSON = this.readPkgJSON(dir);
if (pkgJSON && pkgJSON.name === dep) return pkgJSON;

const parentDir = path.dirname(dir);
if (parentDir === dir) break;

dir = parentDir;
}
} catch (e) {
// Give up! Who knows where the `package.json` is…
}
}
}

private scanDependencies(deps: string[]): void {
const newDeps: string[] = [];
for (const dep of deps) {
// Attempt: package is common and not Astro. ❌ Skip these for perf
if (isCommonNotAstro(dep)) {
this.nonAstroDeps.add(dep);
continue;
}

const pkgJson = this.resolvePkgJSON(dep);
if (!pkgJson) {
this.nonAstroDeps.add(dep);
continue;
}
const { dependencies = {}, peerDependencies = {}, keywords = [] } = pkgJson;

if (
// Attempt: package relies on `astro`. ✅ Definitely an Astro package
peerDependencies.astro ||
dependencies.astro ||
// Attempt: package is tagged with `astro` or `astro-component`. ✅ Likely a community package
keywords.includes('astro') ||
keywords.includes('astro-component') ||
// Attempt: package is named `astro-something` or `@scope/astro-something`. ✅ Likely a community package
/^(@[^\/]+\/)?astro\-/.test(dep)
) {
this.astroDeps.add(dep);
// Collect any dependencies of this Astro package we haven’t seen yet.
const unknownDependencies = Object.keys(dependencies).filter((d) => !this.seen(d));
newDeps.push(...unknownDependencies);
} else {
this.nonAstroDeps.add(dep);
}
}
if (newDeps.length) this.scanDependencies(newDeps);
}
}

const COMMON_DEPENDENCIES_NOT_ASTRO = [
Expand Down Expand Up @@ -256,3 +339,12 @@ function isCommonNotAstro(dep: string): boolean {
)
);
}

interface PkgJSON {
name: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
keywords?: string[];
[key: string]: any;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({});
9 changes: 9 additions & 0 deletions packages/astro/test/fixtures/third-party-astro/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@e2e/third-party-astro",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"astro-embed": "^0.1.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
import { YouTube } from 'astro-embed'
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Third-Party Package Test</title>
</head>
<body>
<main>
<h1>Third-Party .astro test</h1>
<YouTube id="https://youtu.be/xtTy5nKay_Y" />
</main>
</body>
</html>
43 changes: 43 additions & 0 deletions packages/astro/test/third-party-astro.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect } from 'chai';
import * as cheerio from 'cheerio';
import { loadFixture } from './test-utils.js';

describe('third-party .astro component', () => {
let fixture;

before(async () => {
fixture = await loadFixture({
root: './fixtures/third-party-astro/',
});
});

describe('build', () => {
before(async () => {
await fixture.build();
});

it('renders a page using a third-party .astro component', async () => {
const html = await fixture.readFile('/astro-embed/index.html');
const $ = cheerio.load(html);
expect($('h1').text()).to.equal('Third-Party .astro test');
});
});

describe('dev', () => {
let devServer;

before(async () => {
devServer = await fixture.startDevServer();
});

after(async () => {
await devServer.stop();
});

it('renders a page using a third-party .astro component', async () => {
const html = await fixture.fetch('/astro-embed/').then((res) => res.text());
const $ = cheerio.load(html);
expect($('h1').text()).to.equal('Third-Party .astro test');
});
});
});
74 changes: 74 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit eb1862b

Please sign in to comment.