diff options
Diffstat (limited to 'src/compiler/transform')
-rw-r--r-- | src/compiler/transform/doctype.ts | 35 | ||||
-rw-r--r-- | src/compiler/transform/index.ts | 98 | ||||
-rw-r--r-- | src/compiler/transform/module-scripts.ts | 43 | ||||
-rw-r--r-- | src/compiler/transform/postcss-scoped-styles/index.ts | 106 | ||||
-rw-r--r-- | src/compiler/transform/prism.ts | 90 | ||||
-rw-r--r-- | src/compiler/transform/styles.ts | 285 |
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 }); + } + }); + }, + }; +} |