diff options
Diffstat (limited to 'src/compiler/transform')
-rw-r--r-- | src/compiler/transform/doctype.ts | 36 | ||||
-rw-r--r-- | src/compiler/transform/index.ts | 100 | ||||
-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 | 89 | ||||
-rw-r--r-- | src/compiler/transform/styles.ts | 290 |
6 files changed, 0 insertions, 664 deletions
diff --git a/src/compiler/transform/doctype.ts b/src/compiler/transform/doctype.ts deleted file mode 100644 index e871f5b48..000000000 --- a/src/compiler/transform/doctype.ts +++ /dev/null @@ -1,36 +0,0 @@ -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', - }; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - 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 deleted file mode 100644 index 02a98709b..000000000 --- a/src/compiler/transform/index.ts +++ /dev/null @@ -1,100 +0,0 @@ -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)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - 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)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - 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 deleted file mode 100644 index aff1ec4f6..000000000 --- a/src/compiler/transform/module-scripts.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 23350869c..000000000 --- a/src/compiler/transform/postcss-scoped-styles/index.ts +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 1bb024a84..000000000 --- a/src/compiler/transform/prism.ts +++ /dev/null @@ -1,89 +0,0 @@ -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['"]/; -/** escaping code samples that contain template string replacement parts, ${foo} or example. */ -function escape(code: string) { - return code.replace(/[`$]/g, (match) => { - return '\\' + match; - }); -} -/** default export - Transform prism */ -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', - codeChunks: ['`' + escape(code) + '`'], - 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 deleted file mode 100644 index 53585651f..000000000 --- a/src/compiler/transform/styles.ts +++ /dev/null @@ -1,290 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import { createRequire } from 'module'; -import path from 'path'; -import { fileURLToPath } from 'url'; -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 require = createRequire(import.meta.url); - const tw = require.resolve('tailwindcss', { paths: [import.meta.url, process.cwd()] }); - postcssPlugins.push(require(tw) as any); - } catch (err) { - // eslint-disable-next-line no-console - 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(fileURLToPath(compileOptions.astroConfig.projectRoot), 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.codeChunks[0].includes(`' ${scopedClass}'`)) { - // MustacheTag - // FIXME: this won't work when JSX element can appear in attributes (rare but possible). - attr.value[k].expression.codeChunks[0] = `(${attr.value[k].expression.codeChunks[0]}) + ' ${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 }); - } - }); - }, - }; -} |