summaryrefslogtreecommitdiff
path: root/src/compiler/optimize
diff options
context:
space:
mode:
Diffstat (limited to 'src/compiler/optimize')
-rw-r--r--src/compiler/optimize/index.ts83
-rw-r--r--src/compiler/optimize/styles.ts213
2 files changed, 296 insertions, 0 deletions
diff --git a/src/compiler/optimize/index.ts b/src/compiler/optimize/index.ts
new file mode 100644
index 000000000..4f6e54fa5
--- /dev/null
+++ b/src/compiler/optimize/index.ts
@@ -0,0 +1,83 @@
+import { walk } from 'estree-walker';
+import type { Ast, TemplateNode } from '../../parser/interfaces';
+import { NodeVisitor, Optimizer, VisitorFn } from '../../@types/optimizer';
+import optimizeStyles from './styles.js';
+
+interface VisitorCollection {
+ enter: Map<string, VisitorFn[]>;
+ leave: Map<string, VisitorFn[]>;
+}
+
+function addVisitor(visitor: NodeVisitor, collection: VisitorCollection, nodeName: string, event: 'enter' | 'leave') {
+ if (typeof visitor[event] !== 'function') return;
+ if (!collection[event]) collection[event] = new Map<string, VisitorFn[]>();
+
+ const visitors = collection[event].get(nodeName) || [];
+ visitors.push(visitor[event] as any);
+ collection[event].set(nodeName, visitors);
+}
+
+function collectVisitors(optimizer: Optimizer, htmlVisitors: VisitorCollection, cssVisitors: VisitorCollection, finalizers: Array<() => Promise<void>>) {
+ if (optimizer.visitors) {
+ if (optimizer.visitors.html) {
+ for (const [nodeName, visitor] of Object.entries(optimizer.visitors.html)) {
+ addVisitor(visitor, htmlVisitors, nodeName, 'enter');
+ addVisitor(visitor, htmlVisitors, nodeName, 'leave');
+ }
+ }
+ if (optimizer.visitors.css) {
+ for (const [nodeName, visitor] of Object.entries(optimizer.visitors.css)) {
+ addVisitor(visitor, cssVisitors, nodeName, 'enter');
+ addVisitor(visitor, cssVisitors, nodeName, 'leave');
+ }
+ }
+ }
+ finalizers.push(optimizer.finalize);
+}
+
+function createVisitorCollection() {
+ return {
+ enter: new Map<string, VisitorFn[]>(),
+ leave: new Map<string, VisitorFn[]>(),
+ };
+}
+
+function walkAstWithVisitors(tmpl: TemplateNode, collection: VisitorCollection) {
+ walk(tmpl, {
+ enter(node) {
+ if (collection.enter.has(node.type)) {
+ const fns = collection.enter.get(node.type)!;
+ for (let fn of fns) {
+ fn(node);
+ }
+ }
+ },
+ leave(node) {
+ if (collection.leave.has(node.type)) {
+ const fns = collection.leave.get(node.type)!;
+ for (let fn of fns) {
+ fn(node);
+ }
+ }
+ },
+ });
+}
+
+interface OptimizeOptions {
+ filename: string;
+ fileID: string;
+}
+
+export async function optimize(ast: Ast, opts: OptimizeOptions) {
+ const htmlVisitors = createVisitorCollection();
+ const cssVisitors = createVisitorCollection();
+ const finalizers: Array<() => Promise<void>> = [];
+
+ collectVisitors(optimizeStyles(opts), htmlVisitors, cssVisitors, finalizers);
+
+ walkAstWithVisitors(ast.css, cssVisitors);
+ walkAstWithVisitors(ast.html, htmlVisitors);
+
+ // Run all of the finalizer functions in parallel because why not.
+ await Promise.all(finalizers.map((fn) => fn()));
+}
diff --git a/src/compiler/optimize/styles.ts b/src/compiler/optimize/styles.ts
new file mode 100644
index 000000000..691300067
--- /dev/null
+++ b/src/compiler/optimize/styles.ts
@@ -0,0 +1,213 @@
+import crypto from 'crypto';
+import path from 'path';
+import autoprefixer from 'autoprefixer';
+import postcss from 'postcss';
+import postcssModules from 'postcss-modules';
+import findUp from 'find-up';
+import sass from 'sass';
+import { Optimizer } from '../../@types/optimizer';
+import type { TemplateNode } from '../../parser/interfaces';
+
+type StyleType = 'css' | 'scss' | 'sass' | 'postcss';
+
+const getStyleType: Map<string, StyleType> = new Map([
+ ['.css', 'css'],
+ ['.pcss', 'postcss'],
+ ['.sass', 'sass'],
+ ['.scss', 'scss'],
+ ['css', 'css'],
+ ['postcss', 'postcss'],
+ ['sass', 'sass'],
+ ['scss', 'scss'],
+ ['text/css', 'css'],
+ ['text/postcss', 'postcss'],
+ ['text/sass', 'sass'],
+ ['text/scss', 'scss'],
+]);
+
+const SASS_OPTIONS: Partial<sass.Options> = {
+ outputStyle: 'compressed',
+};
+
+/** Should be deterministic, given a unique filename */
+function hashFromFilename(filename: string): string {
+ const hash = crypto.createHash('sha256');
+ return hash
+ .update(filename.replace(/\\/g, '/'))
+ .digest('base64')
+ .toString()
+ .replace(/[^A-Za-z0-9-]/g, '')
+ .substr(0, 8);
+}
+
+export interface StyleTransformResult {
+ css: string;
+ cssModules: Map<string, string>;
+ type: StyleType;
+}
+
+// cache node_modules resolutions for each run. saves looking up the same directory over and over again. blown away on exit.
+const nodeModulesMiniCache = new Map<string, string>();
+
+async function transformStyle(code: string, { type, filename, fileID }: { type?: string; filename: string; fileID: string }): Promise<StyleTransformResult> {
+ let styleType: StyleType = 'css'; // important: assume CSS as default
+ if (type) {
+ styleType = getStyleType.get(type) || styleType;
+ }
+
+ // add file path to includePaths
+ let includePaths: string[] = [path.dirname(filename)];
+
+ // include node_modules to includePaths (allows @use-ing node modules, if it can be located)
+ const cachedNodeModulesDir = nodeModulesMiniCache.get(filename);
+ if (cachedNodeModulesDir) {
+ includePaths.push(cachedNodeModulesDir);
+ } else {
+ const nodeModulesDir = await findUp('node_modules', { type: 'directory', cwd: path.dirname(filename) });
+ if (nodeModulesDir) {
+ nodeModulesMiniCache.set(filename, nodeModulesDir);
+ includePaths.push(nodeModulesDir);
+ }
+ }
+
+ let css = '';
+ switch (styleType) {
+ case 'css': {
+ css = code;
+ break;
+ }
+ case 'sass':
+ case 'scss': {
+ css = sass.renderSync({ ...SASS_OPTIONS, data: code, includePaths }).css.toString('utf8');
+ break;
+ }
+ case 'postcss': {
+ css = code; // TODO
+ break;
+ }
+ default: {
+ throw new Error(`Unsupported: <style type="${styleType}">`);
+ }
+ }
+
+ const cssModules = new Map<string, string>();
+
+ css = await postcss([
+ postcssModules({
+ generateScopedName(name: string) {
+ return `${name}__${hashFromFilename(fileID)}`;
+ },
+ getJSON(_: string, json: any) {
+ Object.entries(json).forEach(([k, v]: any) => {
+ if (k !== v) cssModules.set(k, v);
+ });
+ },
+ }),
+ autoprefixer(),
+ ])
+ .process(css, { from: filename, to: undefined })
+ .then((result) => result.css);
+
+ return {
+ css,
+ cssModules,
+ type: styleType,
+ };
+}
+
+export default function ({ filename, fileID }: { filename: string; fileID: string }): Optimizer {
+ const elementNodes: TemplateNode[] = []; // elements that need CSS Modules class names
+ const styleNodes: TemplateNode[] = []; // <style> tags to be updated
+ const styleTransformPromises: Promise<StyleTransformResult>[] = []; // async style transform results to be finished in finalize();
+ let rootNode: TemplateNode; // root node which needs <style> tags
+
+ return {
+ visitors: {
+ html: {
+ Element: {
+ enter(node) {
+ // Find the root node to inject the <style> tag in later
+ if (node.name === 'head') {
+ rootNode = node; // If this is <head>, this is what we want. Always take this if found. However, this may not always exist (it won’t for Component subtrees).
+ } else if (!rootNode) {
+ rootNode = node; // If no <head> (yet), then take the first element we come to and assume it‘s the “root” (but if we find a <head> later, then override this per the above)
+ }
+
+ for (let attr of node.attributes) {
+ if (attr.name !== 'class') continue;
+ elementNodes.push(node);
+ }
+ },
+ },
+ },
+ // CSS: compile styles, apply CSS Modules scoping
+ css: {
+ Style: {
+ enter(node) {
+ const code = node.content.styles;
+ const typeAttr = (node.attributes || []).find(({ name }: { name: string }) => name === 'lang');
+ styleNodes.push(node);
+ styleTransformPromises.push(transformStyle(code, { type: (typeAttr && typeAttr.value[0] && typeAttr.value[0].raw) || undefined, filename, fileID }));
+
+ // TODO: we should delete the old untransformed <style> node after we’re done.
+ // However, the svelte parser left it in ast.css, not ast.html. At the final step, this just gets ignored, so it will be deleted, in a sense.
+ // If we ever end up scanning ast.css for something else, then we’ll need to actually delete the node (or transform it to the processed version)
+ },
+ },
+ },
+ },
+ async finalize() {
+ const allCssModules = new Map<string, string>(); // note: this may theoretically have conflicts, but when written, it shouldn’t because we’re processing everything per-component (if we change this to run across the whole document at once, revisit this)
+ const styleTransforms = await Promise.all(styleTransformPromises);
+
+ if (!rootNode) {
+ throw new Error(`No root node found`); // TODO: remove this eventually; we should always find it, but for now alert if there’s a bug in our code
+ }
+
+ // 1. transform <style> tags
+ styleTransforms.forEach((result, n) => {
+ if (styleNodes[n].attributes) {
+ // Add to global CSS Module class list for step 2
+ for (const [k, v] of result.cssModules) {
+ allCssModules.set(k, v);
+ }
+
+ // Update original <style> node with finished results
+ styleNodes[n].attributes = styleNodes[n].attributes.map((attr: any) => {
+ if (attr.name === 'type') {
+ attr.value[0].raw = 'text/css';
+ attr.value[0].data = 'text/css';
+ }
+ return attr;
+ });
+ }
+ styleNodes[n].content.styles = result.css;
+ });
+
+ // 2. inject finished <style> tags into root node
+ rootNode.children = [...styleNodes, ...(rootNode.children || [])];
+
+ // 3. update HTML classes
+ for (let i = 0; i < elementNodes.length; i++) {
+ if (!elementNodes[i].attributes) continue;
+ const node = elementNodes[i];
+ for (let j = 0; j < node.attributes.length; j++) {
+ if (node.attributes[j].name !== 'class') continue;
+ const attr = node.attributes[j];
+ for (let k = 0; k < attr.value.length; k++) {
+ if (attr.value[k].type !== 'Text') continue;
+ const elementClassNames = (attr.value[k].raw as string)
+ .split(' ')
+ .map((c) => {
+ let className = c.trim();
+ return allCssModules.get(className) || className; // if className matches exactly, replace; otherwise keep original
+ })
+ .join(' ');
+ attr.value[k].raw = elementClassNames;
+ attr.value[k].data = elementClassNames;
+ }
+ }
+ }
+ },
+ };
+}