Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enhancement/issue 923 real production import attributes #1259

159 changes: 142 additions & 17 deletions packages/cli/src/config/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
return id.replace('\x00', '').replace('?commonjs-proxy', '');
}

function greenwoodResourceLoader (compilation) {
// ConstructableStylesheets, JSON Modules
const externalizedResources = ['css', 'json'];

function greenwoodResourceLoader (compilation, browser = false) {
const resourcePlugins = compilation.config.plugins.filter((plugin) => {
return plugin.type === 'resource';
}).map((plugin) => {
Expand All @@ -21,16 +24,28 @@

return {
name: 'greenwood-resource-loader',
async resolveId(id) {
const normalizedId = cleanRollupId(id); // idUrl.pathname;
const { projectDirectory, userWorkspace } = compilation.context;

if (normalizedId.startsWith('.') && !normalizedId.startsWith(projectDirectory.pathname)) {
async resolveId(id, importer) {
const normalizedId = cleanRollupId(id);
const { userWorkspace } = compilation.context;

// check for non bare paths and resolve them to the user's workspace
// or Greenwood's scratch dir, like when bundling inline <script> tags
if (normalizedId.startsWith('.')) {
const importerUrl = new URL(normalizedId, `file://${importer}`);
const extension = importerUrl.pathname.split('.').pop();
const external = externalizedResources.includes(extension) && browser && !importerUrl.searchParams.has('type');
const isUserWorkspaceUrl = importerUrl.pathname.startsWith(userWorkspace.pathname);
const prefix = normalizedId.startsWith('..') ? './' : '';
const userWorkspaceUrl = new URL(`${prefix}${normalizedId.replace(/\.\.\//g, '')}`, userWorkspace);

if (await checkResourceExists(userWorkspaceUrl)) {
return normalizePathnameForWindows(userWorkspaceUrl);
// if its not in the users workspace, we clean up the dot-dots and check that against the user's workspace
const resolvedUrl = isUserWorkspaceUrl
? importerUrl
: new URL(`${prefix}${normalizedId.replace(/\.\.\//g, '')}`, userWorkspace);

if (await checkResourceExists(resolvedUrl)) {
return {
id: normalizePathnameForWindows(resolvedUrl),
external
};
}
}
},
Expand Down Expand Up @@ -349,7 +364,7 @@
}
}
} else {
// TODO figure out how to handle URL chunk from SSR pages

Check warning on line 367 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected 'todo' comment: 'TODO figure out how to handle URL chunk...'

Check warning on line 367 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected ' TODO' comment: 'TODO figure out how to handle URL chunk...'

Check warning on line 367 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected 'todo' comment: 'TODO figure out how to handle URL chunk...'

Check warning on line 367 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected ' TODO' comment: 'TODO figure out how to handle URL chunk...'
// https://github.com/ProjectEvergreen/greenwood/issues/1163
}

Expand All @@ -364,6 +379,7 @@
};
},

// sync bundles from API routes to the corresponding API route's entry in the manifest (useful for adapters)
generateBundle(options, bundles) {
for (const bundle in bundles) {
const bundleExtension = bundle.split('.').pop();
Expand Down Expand Up @@ -398,7 +414,114 @@
};
}

const getRollupConfigForScriptResources = async (compilation) => {
// sync externalized import attributes usages within browser scripts
// to corresponding static bundles, instead of being bundled and shipped as JavaScript
// e.g. import theme from './theme.css' with { type: 'css' }
// -> import theme from './theme.ab345dcc.css' with { type: 'css' }
//
// this includes:
// - replace all instances of assert with with (until Rollup supports with keyword)
// - sync externalized import attribute paths with bundled CSS paths
function greenwoodSyncImportAttributes(compilation) {
const unbundledAssetsRefMapper = {};
const { basePath } = compilation.config;

return {
name: 'greenwood-sync-import-attributes',

generateBundle(options, bundles) {
const that = this;

for (const bundle in bundles) {
if (bundle.endsWith('.map')) {
return;
}

const { code } = bundles[bundle];
const ast = this.parse(code);

walk.simple(ast, {
// Rollup currently emits externals with assert keyword and
// ideally we get import attributes through the actual AST
// https://github.com/ProjectEvergreen/greenwood/issues/1218
ImportDeclaration(node) {
const { value } = node.source;
const extension = value.split('.').pop();

if (externalizedResources.includes(extension)) {
let preBundled = false;
let inlineOptimization = false;
bundles[bundle].code = bundles[bundle].code.replace(/assert{/g, 'with{');

// check for app level assets, like say a shared theme.css
compilation.resources.forEach((resource) => {
inlineOptimization = resource.optimizationAttr === 'inline' || compilation.config.optimization === 'inline';

if (resource.sourcePathURL.pathname === new URL(value, compilation.context.projectDirectory).pathname && !inlineOptimization) {
bundles[bundle].code = bundles[bundle].code.replace(value, `/${resource.optimizedFileName}`);
preBundled = true;
}
});

// otherwise emit "one-offs" as Rollup assets
if (!preBundled) {
const sourceURL = new URL(value, compilation.context.projectDirectory);
// inline global assets may already be optimized, check for those first
const source = compilation.resources.get(sourceURL.pathname)?.optimizedFileContents
? compilation.resources.get(sourceURL.pathname).optimizedFileContents
: fs.readFileSync(sourceURL, 'utf-8');

const type = 'asset';
const emitConfig = { type, name: value.split('/').pop(), source, needsCodeReference: true };
const ref = that.emitFile(emitConfig);
const importRef = `import.meta.ROLLUP_ASSET_URL_${ref}`;

bundles[bundle].code = bundles[bundle].code.replace(value, `${basePath}/${importRef}`);

if (!unbundledAssetsRefMapper[emitConfig.name]) {
unbundledAssetsRefMapper[emitConfig.name] = {
importers: [],
importRefs: []
};
}

unbundledAssetsRefMapper[emitConfig.name] = {
importers: [...unbundledAssetsRefMapper[emitConfig.name].importers, bundle],
importRefs: [...unbundledAssetsRefMapper[emitConfig.name].importRefs, importRef]
};
}
}
}
});
}
},

// we use write bundle here to handle import.meta.ROLLUP_ASSET_URL_${ref} linking
// since it seems that Rollup will not do it after the bundling hook
// https://github.com/rollup/rollup/blob/v3.29.4/docs/plugin-development/index.md#generatebundle
writeBundle(options, bundles) {
for (const asset in unbundledAssetsRefMapper) {
for (const bundle in bundles) {
const { fileName } = bundles[bundle];
const hash = fileName.split('.')[fileName.split('.').length - 2];

if (fileName.replace(`.${hash}`, '') === asset) {
unbundledAssetsRefMapper[asset].importers.forEach((importer, idx) => {
let contents = fs.readFileSync(new URL(`./${importer}`, compilation.context.outputDir), 'utf-8');

contents = contents.replace(unbundledAssetsRefMapper[asset].importRefs[idx], fileName);

fs.writeFileSync(new URL(`./${importer}`, compilation.context.outputDir), contents);
});
}
}
}
}
};

}

const getRollupConfigForBrowserScripts = async (compilation) => {
const { outputDir } = compilation.context;
const input = [...compilation.resources.values()]
.filter(resource => resource.type === 'script')
Expand All @@ -416,11 +539,13 @@
dir: normalizePathnameForWindows(outputDir),
entryFileNames: '[name].[hash].js',
chunkFileNames: '[name].[hash].js',
assetFileNames: '[name].[hash].[ext]',
sourcemap: true
},
plugins: [
greenwoodResourceLoader(compilation),
greenwoodResourceLoader(compilation, true),
greenwoodSyncPageResourceBundlesPlugin(compilation),
greenwoodSyncImportAttributes(compilation),
greenwoodImportMetaUrl(compilation),
...customRollupPlugins
],
Expand Down Expand Up @@ -456,7 +581,7 @@
}];
};

const getRollupConfigForApis = async (compilation) => {
const getRollupConfigForApiRoutes = async (compilation) => {
const { outputDir, pagesDir, apisDir } = compilation.context;

return [...compilation.manifest.apis.values()]
Expand Down Expand Up @@ -510,7 +635,7 @@
});
};

const getRollupConfigForSsr = async (compilation, input) => {
const getRollupConfigForSsrPages = async (compilation, input) => {
const { outputDir } = compilation.context;

return input.map((filepath) => {
Expand Down Expand Up @@ -562,7 +687,7 @@
};

export {
getRollupConfigForApis,
getRollupConfigForScriptResources,
getRollupConfigForSsr
getRollupConfigForApiRoutes,
getRollupConfigForBrowserScripts,
getRollupConfigForSsrPages
};
14 changes: 8 additions & 6 deletions packages/cli/src/lifecycles/bundle.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable max-depth, max-len */
import fs from 'fs/promises';
import { getRollupConfigForApis, getRollupConfigForScriptResources, getRollupConfigForSsr } from '../config/rollup.config.js';
import { getRollupConfigForApiRoutes, getRollupConfigForBrowserScripts, getRollupConfigForSsrPages } from '../config/rollup.config.js';
import { getAppLayout, getPageLayout, getUserScripts } from '../lib/layout-utils.js';
import { hashString } from '../lib/hashing-utils.js';
import { checkResourceExists, mergeResponse, normalizePathnameForWindows, trackResourcesForRoute } from '../lib/resource-utils.js';
Expand Down Expand Up @@ -216,7 +216,7 @@ async function bundleStyleResources(compilation, resourcePlugins) {

async function bundleApiRoutes(compilation) {
// https://rollupjs.org/guide/en/#differences-to-the-javascript-api
const apiConfigs = await getRollupConfigForApis(compilation);
const apiConfigs = await getRollupConfigForApiRoutes(compilation);

if (apiConfigs.length > 0 && apiConfigs[0].input.length !== 0) {
for (const configIndex in apiConfigs) {
Expand Down Expand Up @@ -314,7 +314,7 @@ async function bundleSsrPages(compilation, optimizePlugins) {
input.push(normalizePathnameForWindows(entryFileUrl));
}

const ssrConfigs = await getRollupConfigForSsr(compilation, input);
const ssrConfigs = await getRollupConfigForSsrPages(compilation, input);

if (ssrConfigs.length > 0 && ssrConfigs[0].input !== '') {
console.info('bundling dynamic pages...');
Expand All @@ -329,7 +329,7 @@ async function bundleSsrPages(compilation, optimizePlugins) {

async function bundleScriptResources(compilation) {
// https://rollupjs.org/guide/en/#differences-to-the-javascript-api
const [rollupConfig] = await getRollupConfigForScriptResources(compilation);
const [rollupConfig] = await getRollupConfigForBrowserScripts(compilation);

if (rollupConfig.input.length !== 0) {
const bundle = await rollup(rollupConfig);
Expand All @@ -353,10 +353,12 @@ const bundleCompilation = async (compilation) => {

console.info('bundling static assets...');

// need styles bundled first for usage with import attributes syncing in Rollup
await bundleStyleResources(compilation, optimizeResourcePlugins);

await Promise.all([
await bundleApiRoutes(compilation),
await bundleScriptResources(compilation),
await bundleStyleResources(compilation, optimizeResourcePlugins)
await bundleScriptResources(compilation)
]);

// bundleSsrPages depends on bundleScriptResources having run first
Expand Down
23 changes: 13 additions & 10 deletions packages/cli/src/plugins/resource/plugin-standard-css.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,22 +303,25 @@ class StandardCssResource extends ResourceInterface {
});
}

async shouldIntercept(url, request) {
const { pathname, searchParams } = url;
async shouldIntercept(url) {
const { pathname } = url;
const ext = pathname.split('.').pop();

return url.protocol === 'file:' && ext === this.extensions[0] && request.headers.get('Accept')?.indexOf('text/javascript') >= 0 && !searchParams.has('type');
return url.protocol === 'file:' && ext === this.extensions[0];
}

async intercept(url, request, response) {
const contents = (await response.text()).replace(/\r?\n|\r/g, ' ').replace(/\\/g, '\\\\');
const body = `const sheet = new CSSStyleSheet();sheet.replaceSync(\`${contents}\`);export default sheet;`;
let body = await response.text();
let headers = {};

return new Response(body, {
headers: {
'Content-Type': 'text/javascript'
}
});
if (request.headers.get('Accept')?.indexOf('text/javascript') >= 0 && !url.searchParams.has('type')) {
const contents = body.replace(/\r?\n|\r/g, ' ').replace(/\\/g, '\\\\');

body = `const sheet = new CSSStyleSheet();sheet.replaceSync(\`${contents}\`);export default sheet;`;
headers['Content-Type'] = 'text/javascript';
}

return new Response(body, { headers });
}

async shouldOptimize(url, response) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import fs from 'fs';
import glob from 'glob-promise';
import path from 'path';
import { Runner } from 'gallinago';
import { getOutputTeardownFiles } from '../../../../../test/utils.js';
import { fileURLToPath, URL } from 'url';

const expect = chai.expect;
Expand All @@ -52,26 +53,34 @@ describe('Build Greenwood With: ', function() {
runner.runCommand(cliPath, 'build');
});

describe('Importing CSS w/ Constructable Stylesheets', function() {
describe('Custom Element Importing CSS w/ Constructable Stylesheet', function() {
const cssFileHash = 'bcdce3a3';
let scripts;
let styles;

before(async function() {
scripts = await glob.promise(path.join(outputPath, 'public/card.*.js'));
styles = await glob.promise(path.join(outputPath, `public/card.${cssFileHash}.css`));
});

it('should have the expected output from importing hero.css as a Constructable Stylesheet', function() {
it('should have the expected import attribute for importing card.css as a Constructable Stylesheet in the card.js bundle', function() {
const scriptContents = fs.readFileSync(scripts[0], 'utf-8');

expect(scriptContents).to.contain('const e=new CSSStyleSheet;e.replaceSync(":host { color: red; }");');
expect(scripts.length).to.equal(1);
expect(scriptContents).to.contain(`import e from"/card.${cssFileHash}.css"with{type:"css"}`);
});

it('should have the expected CSS output bundle for card.css', function() {
const styleContents = fs.readFileSync(styles[0], 'utf-8');

expect(styles.length).to.equal(1);
expect(styleContents).to.contain(':host {\n color: red;\n}');
});
});
});

after(function() {
runner.stopCommand();
runner.teardown([
path.join(outputPath, '.greenwood'),
path.join(outputPath, 'node_modules')
]);
runner.teardown(getOutputTeardownFiles(outputPath));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,28 @@ describe('Build Greenwood With: ', function() {

runSmokeTest(['public'], LABEL);

describe('Importing CSS w/ Constructable Stylesheets', function() {
describe('Custom Element Importing CSS w/ Constructable Stylesheet', function() {
const cssFileHash = '93e8cf36';
let scripts;
let styles;

before(async function() {
scripts = await glob.promise(path.join(this.context.publicDir, '*.js'));
scripts = await glob.promise(path.join(outputPath, 'public/hero.*.js'));
styles = await glob.promise(path.join(outputPath, `public/hero.${cssFileHash}.css`));
});

// TODO is this actually the output we want here?
// https://github.com/ProjectEvergreen/greenwood/discussions/1216
it('should have the expected output from importing hero.css as a Constructable Stylesheet', function() {
it('should have the expected import attribute for importing hero.css as a Constructable Stylesheet in the hero.js bundle', function() {
const scriptContents = fs.readFileSync(scripts[0], 'utf-8');

expect(scriptContents).to.contain('const t=new CSSStyleSheet;t.replaceSync(":host { text-align: center');
expect(scripts.length).to.equal(1);
expect(scriptContents).to.contain('import e from"/hero.93e8cf36.css"with{type:"css"}');
});

it('should have the expected CSS output bundle for hero.css', function() {
const styleContents = fs.readFileSync(styles[0], 'utf-8');

expect(styles.length).to.equal(1);
expect(styleContents).to.contain(':host {\n text-align: center;');
});
});

Expand Down
Loading
Loading