summaryrefslogtreecommitdiff
path: root/src/compiler/transform
diff options
context:
space:
mode:
Diffstat (limited to 'src/compiler/transform')
-rw-r--r--src/compiler/transform/doctype.ts35
-rw-r--r--src/compiler/transform/index.ts98
-rw-r--r--src/compiler/transform/module-scripts.ts43
-rw-r--r--src/compiler/transform/postcss-scoped-styles/index.ts106
-rw-r--r--src/compiler/transform/prism.ts90
-rw-r--r--src/compiler/transform/styles.ts285
6 files changed, 657 insertions, 0 deletions
diff --git a/src/compiler/transform/doctype.ts b/src/compiler/transform/doctype.ts
new file mode 100644
index 000000000..d19b01f81
--- /dev/null
+++ b/src/compiler/transform/doctype.ts
@@ -0,0 +1,35 @@
+import { Transformer } from '../../@types/transformer';
+
+/** Transform <!doctype> tg */
+export default function (_opts: { filename: string; fileID: string }): Transformer {
+ let hasDoctype = false;
+
+ return {
+ visitors: {
+ html: {
+ Element: {
+ enter(node, parent, _key, index) {
+ if (node.name === '!doctype') {
+ hasDoctype = true;
+ }
+ if (node.name === 'html' && !hasDoctype) {
+ const dtNode = {
+ start: 0,
+ end: 0,
+ attributes: [{ type: 'Attribute', name: 'html', value: true, start: 0, end: 0 }],
+ children: [],
+ name: '!doctype',
+ type: 'Element',
+ };
+ parent.children!.splice(index, 0, dtNode);
+ hasDoctype = true;
+ }
+ },
+ },
+ },
+ },
+ async finalize() {
+ // Nothing happening here.
+ },
+ };
+}
diff --git a/src/compiler/transform/index.ts b/src/compiler/transform/index.ts
new file mode 100644
index 000000000..6a81b92b0
--- /dev/null
+++ b/src/compiler/transform/index.ts
@@ -0,0 +1,98 @@
+import type { Ast, TemplateNode } from '../../parser/interfaces';
+import type { NodeVisitor, TransformOptions, Transformer, VisitorFn } from '../../@types/transformer';
+
+import { walk } from 'estree-walker';
+
+// Transformers
+import transformStyles from './styles.js';
+import transformDoctype from './doctype.js';
+import transformModuleScripts from './module-scripts.js';
+import transformCodeBlocks from './prism.js';
+
+interface VisitorCollection {
+ enter: Map<string, VisitorFn[]>;
+ leave: Map<string, VisitorFn[]>;
+}
+
+/** Add visitors to given collection */
+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);
+}
+
+/** Compile visitor actions from transformer */
+function collectVisitors(transformer: Transformer, htmlVisitors: VisitorCollection, cssVisitors: VisitorCollection, finalizers: Array<() => Promise<void>>) {
+ if (transformer.visitors) {
+ if (transformer.visitors.html) {
+ for (const [nodeName, visitor] of Object.entries(transformer.visitors.html)) {
+ addVisitor(visitor, htmlVisitors, nodeName, 'enter');
+ addVisitor(visitor, htmlVisitors, nodeName, 'leave');
+ }
+ }
+ if (transformer.visitors.css) {
+ for (const [nodeName, visitor] of Object.entries(transformer.visitors.css)) {
+ addVisitor(visitor, cssVisitors, nodeName, 'enter');
+ addVisitor(visitor, cssVisitors, nodeName, 'leave');
+ }
+ }
+ }
+ finalizers.push(transformer.finalize);
+}
+
+/** Utility for formatting visitors */
+function createVisitorCollection() {
+ return {
+ enter: new Map<string, VisitorFn[]>(),
+ leave: new Map<string, VisitorFn[]>(),
+ };
+}
+
+/** Walk AST with collected visitors */
+function walkAstWithVisitors(tmpl: TemplateNode, collection: VisitorCollection) {
+ walk(tmpl, {
+ enter(node, parent, key, index) {
+ if (collection.enter.has(node.type)) {
+ const fns = collection.enter.get(node.type)!;
+ for (let fn of fns) {
+ fn.call(this, node, parent, key, index);
+ }
+ }
+ },
+ leave(node, parent, key, index) {
+ if (collection.leave.has(node.type)) {
+ const fns = collection.leave.get(node.type)!;
+ for (let fn of fns) {
+ fn.call(this, node, parent, key, index);
+ }
+ }
+ },
+ });
+}
+
+/**
+ * Transform
+ * Step 2/3 in Astro SSR.
+ * Transform is the point at which we mutate the AST before sending off to
+ * Codegen, and then to Snowpack. In some ways, it‘s a preprocessor.
+ */
+export async function transform(ast: Ast, opts: TransformOptions) {
+ const htmlVisitors = createVisitorCollection();
+ const cssVisitors = createVisitorCollection();
+ const finalizers: Array<() => Promise<void>> = [];
+
+ const optimizers = [transformStyles(opts), transformDoctype(opts), transformModuleScripts(opts), transformCodeBlocks(ast.module)];
+
+ for (const optimizer of optimizers) {
+ collectVisitors(optimizer, 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/transform/module-scripts.ts b/src/compiler/transform/module-scripts.ts
new file mode 100644
index 000000000..aff1ec4f6
--- /dev/null
+++ b/src/compiler/transform/module-scripts.ts
@@ -0,0 +1,43 @@
+import type { Transformer } from '../../@types/transformer';
+import type { CompileOptions } from '../../@types/compiler';
+
+import path from 'path';
+import { getAttrValue, setAttrValue } from '../../ast.js';
+
+/** Transform <script type="module"> */
+export default function ({ compileOptions, filename }: { compileOptions: CompileOptions; filename: string; fileID: string }): Transformer {
+ const { astroConfig } = compileOptions;
+ const { astroRoot } = astroConfig;
+ const fileUrl = new URL(`file://${filename}`);
+
+ return {
+ visitors: {
+ html: {
+ Element: {
+ enter(node) {
+ let name = node.name;
+ if (name !== 'script') {
+ return;
+ }
+
+ let type = getAttrValue(node.attributes, 'type');
+ if (type !== 'module') {
+ return;
+ }
+
+ let src = getAttrValue(node.attributes, 'src');
+ if (!src || !src.startsWith('.')) {
+ return;
+ }
+
+ const srcUrl = new URL(src, fileUrl);
+ const fromAstroRoot = path.posix.relative(astroRoot.pathname, srcUrl.pathname);
+ const absoluteUrl = `/_astro/${fromAstroRoot}`;
+ setAttrValue(node.attributes, 'src', absoluteUrl);
+ },
+ },
+ },
+ },
+ async finalize() {},
+ };
+}
diff --git a/src/compiler/transform/postcss-scoped-styles/index.ts b/src/compiler/transform/postcss-scoped-styles/index.ts
new file mode 100644
index 000000000..23350869c
--- /dev/null
+++ b/src/compiler/transform/postcss-scoped-styles/index.ts
@@ -0,0 +1,106 @@
+import { Declaration, Plugin } from 'postcss';
+
+interface AstroScopedOptions {
+ className: string;
+}
+
+interface Selector {
+ start: number;
+ end: number;
+ value: string;
+}
+
+const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
+const KEYFRAME_PERCENT = /\d+\.?\d*%/;
+
+/** HTML tags that should never get scoped classes */
+export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']);
+
+/**
+ * Scope Rules
+ * Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`)
+ * @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax).
+ * @param {string} className The CSS class to apply.
+ */
+export function scopeRule(selector: string, className: string) {
+ // if this is a keyframe keyword, return original selector
+ if (selector === 'from' || selector === 'to' || KEYFRAME_PERCENT.test(selector)) {
+ return selector;
+ }
+
+ // For everything else, parse & scope
+ const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
+ const selectors: Selector[] = [];
+ let ss = selector; // final output
+
+ // Pass 1: parse selector string; extract top-level selectors
+ {
+ let start = 0;
+ let lastValue = '';
+ let parensOpen = false;
+ for (let n = 0; n < ss.length; n++) {
+ const isEnd = n === selector.length - 1;
+ if (selector[n] === '(') parensOpen = true;
+ if (selector[n] === ')') parensOpen = false;
+ if (isEnd || (parensOpen === false && CSS_SEPARATORS.has(selector[n]))) {
+ lastValue = selector.substring(start, isEnd ? undefined : n);
+ if (!lastValue) continue;
+ selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
+ start = n + 1;
+ }
+ }
+ }
+
+ // Pass 2: starting from end, transform selectors w/ scoped class
+ for (let i = selectors.length - 1; i >= 0; i--) {
+ const { start, end, value } = selectors[i];
+ const head = ss.substring(0, start);
+ const tail = ss.substring(end);
+
+ // replace '*' with className
+ if (value === '*') {
+ ss = head + c + tail;
+ continue;
+ }
+
+ // leave :global() alone!
+ if (value.startsWith(':global(')) {
+ ss =
+ head +
+ ss
+ .substring(start, end)
+ .replace(/^:global\(/, '')
+ .replace(/\)$/, '') +
+ tail;
+ continue;
+ }
+
+ // don‘t scope body, title, etc.
+ if (NEVER_SCOPED_TAGS.has(value)) {
+ ss = head + value + tail;
+ continue;
+ }
+
+ // scope everything else
+ let newSelector = ss.substring(start, end);
+ const pseudoIndex = newSelector.indexOf(':');
+ if (pseudoIndex > 0) {
+ // if there‘s a pseudoclass (:focus)
+ ss = head + newSelector.substring(start, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail;
+ } else {
+ ss = head + newSelector + c + tail;
+ }
+ }
+
+ return ss;
+}
+
+/** PostCSS Scope plugin */
+export default function astroScopedStyles(options: AstroScopedOptions): Plugin {
+ return {
+ postcssPlugin: '@astro/postcss-scoped-styles',
+ Rule(rule) {
+ rule.selector = scopeRule(rule.selector, options.className);
+ },
+ };
+}
diff --git a/src/compiler/transform/prism.ts b/src/compiler/transform/prism.ts
new file mode 100644
index 000000000..628dcce7e
--- /dev/null
+++ b/src/compiler/transform/prism.ts
@@ -0,0 +1,90 @@
+import type { Transformer } from '../../@types/transformer';
+import type { Script } from '../../parser/interfaces';
+import { getAttrValue } from '../../ast.js';
+
+const PRISM_IMPORT = `import Prism from 'astro/components/Prism.astro';\n`;
+const prismImportExp = /import Prism from ['"]astro\/components\/Prism.astro['"]/;
+
+function escape(code: string) {
+ return code.replace(/[`$]/g, (match) => {
+ return '\\' + match;
+ });
+}
+
+export default function (module: Script): Transformer {
+ let usesPrism = false;
+
+ return {
+ visitors: {
+ html: {
+ Element: {
+ enter(node) {
+ if (node.name !== 'code') return;
+ const className = getAttrValue(node.attributes, 'class') || '';
+ const classes = className.split(' ');
+
+ let lang;
+ for (let cn of classes) {
+ const matches = /language-(.+)/.exec(cn);
+ if (matches) {
+ lang = matches[1];
+ }
+ }
+
+ if (!lang) return;
+
+ let code;
+ if (node.children?.length) {
+ code = node.children[0].data;
+ }
+
+ const repl = {
+ start: 0,
+ end: 0,
+ type: 'InlineComponent',
+ name: 'Prism',
+ attributes: [
+ {
+ type: 'Attribute',
+ name: 'lang',
+ value: [
+ {
+ type: 'Text',
+ raw: lang,
+ data: lang,
+ },
+ ],
+ },
+ {
+ type: 'Attribute',
+ name: 'code',
+ value: [
+ {
+ type: 'MustacheTag',
+ expression: {
+ type: 'Expression',
+ codeStart: '`' + escape(code) + '`',
+ codeEnd: '',
+ children: []
+ }
+ },
+ ],
+ },
+ ],
+ children: [],
+ };
+
+ this.replace(repl);
+ usesPrism = true;
+ },
+ },
+ },
+ },
+ async finalize() {
+ // Add the Prism import if needed.
+ if (usesPrism && !prismImportExp.test(module.content)) {
+ module.content = PRISM_IMPORT + module.content;
+ }
+ },
+ };
+}
diff --git a/src/compiler/transform/styles.ts b/src/compiler/transform/styles.ts
new file mode 100644
index 000000000..d8e3196a1
--- /dev/null
+++ b/src/compiler/transform/styles.ts
@@ -0,0 +1,285 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import autoprefixer from 'autoprefixer';
+import postcss, { Plugin } from 'postcss';
+import postcssKeyframes from 'postcss-icss-keyframes';
+import findUp from 'find-up';
+import sass from 'sass';
+import type { RuntimeMode } from '../../@types/astro';
+import type { TransformOptions, Transformer } from '../../@types/transformer';
+import type { TemplateNode } from '../../parser/interfaces';
+import { debug } from '../../logger.js';
+import astroScopedStyles, { NEVER_SCOPED_TAGS } from './postcss-scoped-styles/index.js';
+
+type StyleType = 'css' | 'scss' | 'sass' | 'postcss';
+
+declare global {
+ interface ImportMeta {
+ /** https://nodejs.org/api/esm.html#esm_import_meta_resolve_specifier_parent */
+ resolve(specifier: string, parent?: string): Promise<any>;
+ }
+}
+
+const getStyleType: Map<string, StyleType> = new Map([
+ ['.css', 'css'],
+ ['.pcss', 'postcss'],
+ ['.sass', 'sass'],
+ ['.scss', 'scss'],
+ ['css', 'css'],
+ ['sass', 'sass'],
+ ['scss', 'scss'],
+ ['text/css', 'css'],
+ ['text/sass', 'sass'],
+ ['text/scss', 'scss'],
+]);
+
+/** 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;
+ type: StyleType;
+}
+
+interface StylesMiniCache {
+ nodeModules: Map<string, string>; // filename: node_modules location
+ tailwindEnabled?: boolean; // cache once per-run
+}
+
+/** Simple cache that only exists in memory per-run. Prevents the same lookups from happening over and over again within the same build or dev server session. */
+const miniCache: StylesMiniCache = {
+ nodeModules: new Map<string, string>(),
+};
+
+export interface TransformStyleOptions {
+ type?: string;
+ filename: string;
+ scopedClass: string;
+ mode: RuntimeMode;
+}
+
+/** given a class="" string, does it contain a given class? */
+function hasClass(classList: string, className: string): boolean {
+ if (!className) return false;
+ for (const c of classList.split(' ')) {
+ if (className === c.trim()) return true;
+ }
+ return false;
+}
+
+/** Convert styles to scoped CSS */
+async function transformStyle(code: string, { type, filename, scopedClass, mode }: TransformStyleOptions): 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 = miniCache.nodeModules.get(filename);
+ if (cachedNodeModulesDir) {
+ includePaths.push(cachedNodeModulesDir);
+ } else {
+ const nodeModulesDir = await findUp('node_modules', { type: 'directory', cwd: path.dirname(filename) });
+ if (nodeModulesDir) {
+ miniCache.nodeModules.set(filename, nodeModulesDir);
+ includePaths.push(nodeModulesDir);
+ }
+ }
+
+ // 1. Preprocess (currently only Sass supported)
+ let css = '';
+ switch (styleType) {
+ case 'css': {
+ css = code;
+ break;
+ }
+ case 'sass':
+ case 'scss': {
+ css = sass.renderSync({ data: code, includePaths }).css.toString('utf8');
+ break;
+ }
+ default: {
+ throw new Error(`Unsupported: <style lang="${styleType}">`);
+ }
+ }
+
+ // 2. Post-process (PostCSS)
+ const postcssPlugins: Plugin[] = [];
+
+ // 2a. Tailwind (only if project uses Tailwind)
+ if (miniCache.tailwindEnabled) {
+ try {
+ const { default: tailwindcss } = await import('@tailwindcss/jit');
+ postcssPlugins.push(tailwindcss());
+ } catch (err) {
+ console.error(err);
+ throw new Error(`tailwindcss not installed. Try running \`npm install tailwindcss\` and trying again.`);
+ }
+ }
+
+ // 2b. Astro scoped styles (always on)
+ postcssPlugins.push(astroScopedStyles({ className: scopedClass }));
+
+ // 2c. Scoped @keyframes
+ postcssPlugins.push(
+ postcssKeyframes({
+ generateScopedName(keyframesName) {
+ return `${keyframesName}-${scopedClass}`;
+ },
+ })
+ );
+
+ // 2d. Autoprefixer (always on)
+ postcssPlugins.push(autoprefixer());
+
+ // 2e. Run PostCSS
+ css = await postcss(postcssPlugins)
+ .process(css, { from: filename, to: undefined })
+ .then((result) => result.css);
+
+ return { css, type: styleType };
+}
+
+/** Transform <style> tags */
+export default function transformStyles({ compileOptions, filename, fileID }: TransformOptions): Transformer {
+ const styleNodes: TemplateNode[] = []; // <style> tags to be updated
+ const styleTransformPromises: Promise<StyleTransformResult>[] = []; // async style transform results to be finished in finalize();
+ const scopedClass = `astro-${hashFromFilename(fileID)}`; // this *should* generate same hash from fileID every time
+
+ // find Tailwind config, if first run (cache for subsequent runs)
+ if (miniCache.tailwindEnabled === undefined) {
+ const tailwindNames = ['tailwind.config.js', 'tailwind.config.mjs'];
+ for (const loc of tailwindNames) {
+ const tailwindLoc = path.join(compileOptions.astroConfig.projectRoot.pathname, loc);
+ if (fs.existsSync(tailwindLoc)) {
+ miniCache.tailwindEnabled = true; // Success! We have a Tailwind config file.
+ debug(compileOptions.logging, 'tailwind', 'Found config. Enabling.');
+ break;
+ }
+ }
+ if (miniCache.tailwindEnabled !== true) miniCache.tailwindEnabled = false; // We couldn‘t find one; mark as false
+ debug(compileOptions.logging, 'tailwind', 'No config found. Skipping.');
+ }
+
+ return {
+ visitors: {
+ html: {
+ Element: {
+ enter(node) {
+ // 1. if <style> tag, transform it and continue to next node
+ if (node.name === 'style') {
+ // Same as ast.css (below)
+ const code = Array.isArray(node.children) ? node.children.map(({ data }: any) => data).join('\n') : '';
+ if (!code) return;
+ const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang');
+ styleNodes.push(node);
+ styleTransformPromises.push(
+ transformStyle(code, {
+ type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
+ filename,
+ scopedClass,
+ mode: compileOptions.mode,
+ })
+ );
+ return;
+ }
+
+ // 2. add scoped HTML classes
+ if (NEVER_SCOPED_TAGS.has(node.name)) return; // only continue if this is NOT a <script> tag, etc.
+ // Note: currently we _do_ scope web components/custom elements. This seems correct?
+
+ if (!node.attributes) node.attributes = [];
+ const classIndex = node.attributes.findIndex(({ name }: any) => name === 'class');
+ if (classIndex === -1) {
+ // 3a. element has no class="" attribute; add one and append scopedClass
+ node.attributes.push({ start: -1, end: -1, type: 'Attribute', name: 'class', value: [{ type: 'Text', raw: scopedClass, data: scopedClass }] });
+ } else {
+ // 3b. element has class=""; append scopedClass
+ const attr = node.attributes[classIndex];
+ for (let k = 0; k < attr.value.length; k++) {
+ if (attr.value[k].type === 'Text') {
+ // don‘t add same scopedClass twice
+ if (!hasClass(attr.value[k].data, scopedClass)) {
+ // string literal
+ attr.value[k].raw += ' ' + scopedClass;
+ attr.value[k].data += ' ' + scopedClass;
+ }
+ } else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) {
+ // don‘t add same scopedClass twice (this check is a little more basic, but should suffice)
+ if (!attr.value[k].expression.codeStart.includes(`' ${scopedClass}'`)) {
+ // MustacheTag
+ attr.value[k].expression.codeStart = `(${attr.value[k].expression.codeStart}) + ' ${scopedClass}'`;
+ }
+ }
+ }
+ }
+ },
+ },
+ },
+ // CSS: compile styles, apply CSS Modules scoping
+ css: {
+ Style: {
+ enter(node) {
+ // Same as ast.html (above)
+ // Note: this is duplicated from html because of the compiler we‘re using; in a future version we should combine these
+ if (!node.content || !node.content.styles) return;
+ const code = node.content.styles;
+ const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang');
+ styleNodes.push(node);
+ styleTransformPromises.push(
+ transformStyle(code, {
+ type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
+ filename,
+ scopedClass,
+ mode: compileOptions.mode,
+ })
+ );
+ },
+ },
+ },
+ },
+ async finalize() {
+ const styleTransforms = await Promise.all(styleTransformPromises);
+
+ styleTransforms.forEach((result, n) => {
+ if (styleNodes[n].attributes) {
+ // 1. Replace with final CSS
+ const isHeadStyle = !styleNodes[n].content;
+ if (isHeadStyle) {
+ // Note: <style> tags in <head> have different attributes/rules, because of the parser. Unknown why
+ (styleNodes[n].children as any) = [{ ...(styleNodes[n].children as any)[0], data: result.css }];
+ } else {
+ styleNodes[n].content.styles = result.css;
+ }
+
+ // 2. Update <style> attributes
+ const styleTypeIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'type');
+ // add type="text/css"
+ if (styleTypeIndex !== -1) {
+ styleNodes[n].attributes[styleTypeIndex].value[0].raw = 'text/css';
+ styleNodes[n].attributes[styleTypeIndex].value[0].data = 'text/css';
+ } else {
+ styleNodes[n].attributes.push({ name: 'type', type: 'Attribute', value: [{ type: 'Text', raw: 'text/css', data: 'text/css' }] });
+ }
+ // remove lang="*"
+ const styleLangIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'lang');
+ if (styleLangIndex !== -1) styleNodes[n].attributes.splice(styleLangIndex, 1);
+ // TODO: add data-astro for later
+ // styleNodes[n].attributes.push({ name: 'data-astro', type: 'Attribute', value: true });
+ }
+ });
+ },
+ };
+}