Skip to content

Commit

Permalink
enhancement/issue 923 real production import attributes (#1259)
Browse files Browse the repository at this point in the history
* WIP real production import attributes

* restore all test cases to passing

* all test cases and website building correctly

* rollup resolveId refactoring

* rename rollup configs to be more explicit

* refine test cases

* clean up console logs and TODOs

* add import attributes to the docs

* handle base path for bundled import attributes references
  • Loading branch information
thescientist13 authored Jul 31, 2024
1 parent 56f8c7d commit 205352e
Show file tree
Hide file tree
Showing 10 changed files with 233 additions and 55 deletions.
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 @@ function cleanRollupId(id) {
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 @@ function greenwoodResourceLoader (compilation) {

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 @@ -364,6 +379,7 @@ function greenwoodImportMetaUrl(compilation) {
};
},

// 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 @@ function greenwoodImportMetaUrl(compilation) {
};
}

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 @@ const getRollupConfigForScriptResources = async (compilation) => {
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 getRollupConfigForScriptResources = async (compilation) => {
}];
};

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 getRollupConfigForApis = async (compilation) => {
});
};

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 @@ const getRollupConfigForSsr = async (compilation, input) => {
};

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

0 comments on commit 205352e

Please sign in to comment.