import type { Ast, Script, Style, TemplateNode, Expression } from '@astrojs/parser'; import type { CompileOptions } from '../../@types/compiler'; import type { AstroConfig, AstroMarkdownOptions, TransformResult, ComponentInfo, Components } from '../../@types/astro'; import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, ImportDefaultSpecifier } from '@babel/types'; import eslexer from 'es-module-lexer'; import esbuild from 'esbuild'; import path from 'path'; import astroParser from '@astrojs/parser'; import { walk, asyncWalk } from 'estree-walker'; import _babelGenerator from '@babel/generator'; import babelParser from '@babel/parser'; import { codeFrameColumns } from '@babel/code-frame'; import * as babelTraverse from '@babel/traverse'; import { error, warn, parseError } from '../../logger.js'; import { fetchContent } from './content.js'; import { isFetchContent } from './utils.js'; import { yellow } from 'kleur/colors'; import { isComponentTag, isCustomElementTag, positionAt } from '../utils.js'; import { renderMarkdown } from '@astrojs/markdown-support'; import { camelCase } from 'camel-case'; import { transform } from '../transform/index.js'; import { PRISM_IMPORT } from '../transform/prism.js'; import { nodeBuiltinsSet } from '../../node_builtins.js'; import { readFileSync } from 'fs'; import { pathToFileURL } from 'url'; const { parse, FEATURE_CUSTOM_ELEMENT } = astroParser; const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default; // @ts-ignore const babelGenerator: typeof _babelGenerator = _babelGenerator.default; const { transformSync } = esbuild; interface Attribute { start: number; end: number; type: 'Attribute' | 'Spread'; name: string; value: TemplateNode[] | boolean; expression?: Expression; } interface CodeGenOptions { compileOptions: CompileOptions; filename: string; fileID: string; } interface HydrationAttributes { method?: 'load' | 'idle' | 'visible' | 'media'; value?: undefined | string; } /** Searches through attributes to extract hydration-rlated attributes */ function findHydrationAttributes(attrs: Record): HydrationAttributes { let method: HydrationAttributes['method']; let value: undefined | string; const hydrationDirectives = new Set(['client:load', 'client:idle', 'client:visible', 'client:media']); for (const [key, val] of Object.entries(attrs)) { if (hydrationDirectives.has(key)) { method = key.slice(7) as HydrationAttributes['method']; value = val === 'true' ? undefined : val; } } return { method, value }; } /** Retrieve attributes from TemplateNode */ async function getAttributes(attrs: Attribute[], state: CodegenState, compileOptions: CompileOptions): Promise> { let result: Record = {}; for (const attr of attrs) { if (attr.type === 'Spread') { const code = await compileExpression(attr.expression as Expression, state, compileOptions); if (code) { result[`...(${code})`] = ''; } continue; } if (attr.value === true) { result[attr.name] = JSON.stringify(attr.value); continue; } if (attr.value === false || attr.value === undefined) { // note: attr.value shouldn’t be `undefined`, but a bad transform would cause a compile error here, so prevent that continue; } if (attr.value.length === 0) { result[attr.name] = '""'; continue; } if (attr.value.length > 1) { result[attr.name] = '(' + attr.value .map((v: TemplateNode) => { if (v.content) { return v.content; } else { return JSON.stringify(getTextFromAttribute(v)); } }) .join('+') + ')'; continue; } const val = attr.value[0]; if (!val) { result[attr.name] = '(' + val + ')'; continue; } switch (val.type) { case 'MustacheTag': { const code = await compileExpression(val.expression, state, compileOptions); if (code) { result[attr.name] = '(' + code + ')'; } continue; } case 'Text': result[attr.name] = JSON.stringify(getTextFromAttribute(val)); continue; case 'AttributeShorthand': result[attr.name] = '(' + attr.name + ')'; continue; default: throw new Error(`UNKNOWN: ${val.type}`); } } return result; } /** Get value from a TemplateNode Attribute (text attributes only!) */ function getTextFromAttribute(attr: any): string { switch (attr.type) { case 'Text': { if (attr.raw !== undefined) { return attr.raw; } if (attr.data !== undefined) { return attr.data; } break; } case 'MustacheTag': { // FIXME: this won't work when JSX element can appear in attributes (rare but possible). return attr.expression.codeChunks[0]; } } throw new Error(`Unknown attribute type ${attr.type}`); } /** Convert TemplateNode attributes to string */ function generateAttributes(attrs: Record): string { let result = '{'; for (const [key, val] of Object.entries(attrs)) { if (key.startsWith('...')) { result += key + ','; } else { result += JSON.stringify(key) + ':' + val + ','; } } return result + '}'; } function getComponentUrl(astroConfig: AstroConfig, url: string, parentUrl: string | URL) { const componentExt = path.extname(url); const ext = PlainExtensions.has(componentExt) ? '.js' : `${componentExt}.js`; const outUrl = new URL(url, parentUrl); return '/_astro/' + outUrl.href.replace(astroConfig.projectRoot.href, '').replace(/\.[^.]+$/, ext); } interface GetComponentWrapperOptions { filename: string; astroConfig: AstroConfig; compileOptions: CompileOptions; } const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']); /** Generate Astro-friendly component import */ function getComponentWrapper(_name: string, hydration: HydrationAttributes, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) { const { astroConfig, filename } = opts; let name = _name; let method = hydration.method; /** Legacy support for original hydration syntax */ if (name.indexOf(':') > 0) { const [legacyName, legacyMethod] = _name.split(':'); name = legacyName; method = legacyMethod as HydrationAttributes['method']; const { compileOptions, filename } = opts; const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, filename); warn(compileOptions.logging, shortname, yellow(`Deprecation warning: Partial hydration now uses a directive syntax. Please update to "<${name} client:${method} />"`)); } // Special flow for custom elements if (isCustomElementTag(_name)) { return { wrapper: `__astro_component(...__astro_element_registry.astroComponentArgs("${name}", ${JSON.stringify({ hydrate: method, displayName: _name })}))`, wrapperImports: [ `import {AstroElementRegistry} from 'astro/dist/internal/element-registry.js';`, `import {__astro_component} from 'astro/dist/internal/__astro_component.js';`, ], }; } else { const getComponentExport = () => { switch (importSpecifier.type) { case 'ImportDefaultSpecifier': return { value: 'default' }; case 'ImportSpecifier': { if (importSpecifier.imported.type === 'Identifier') { return { value: importSpecifier.imported.name }; } return { value: importSpecifier.imported.value }; } case 'ImportNamespaceSpecifier': { const [_, value] = _name.split('.'); return { value }; } } }; let metadata: string = ''; if (method) { const componentUrl = getComponentUrl(astroConfig, url, pathToFileURL(filename)); const componentExport = getComponentExport(); metadata = `{ hydrate: "${method}", displayName: "${name}", componentUrl: "${componentUrl}", componentExport: ${JSON.stringify(componentExport)}, value: ${ hydration.value || 'null' } }`; } else { metadata = `{ hydrate: undefined, displayName: "${name}", value: ${hydration.value || 'null'} }`; } return { wrapper: `__astro_component(${name}, ${metadata})`, wrapperImports: [`import {__astro_component} from 'astro/dist/internal/__astro_component.js';`], }; } } /** * Convert an Expression Node to a string * * @param expression Expression Node to compile * @param state CodegenState * @param compileOptions CompileOptions */ async function compileExpression(node: Expression, state: CodegenState, compileOptions: CompileOptions) { const children: string[] = await Promise.all((node.children ?? []).map((child) => compileHtml(child, state, compileOptions))); let raw = ''; let nextChildIndex = 0; for (const chunk of node.codeChunks) { raw += chunk; if (nextChildIndex < children.length) { raw += children[nextChildIndex++]; } } const location = { start: node.start, end: node.end }; let code = transpileExpressionSafe('(' + raw + ')', { state, compileOptions, location }); if (code === null) throw new Error(`Unable to compile expression`); code = code.trim().replace(/\;$/, ''); return code; } /** Evaluate expression (safely) */ function transpileExpressionSafe( raw: string, { state, compileOptions, location }: { state: CodegenState; compileOptions: CompileOptions; location: { start: number; end: number } } ): string | null { try { let { code } = transformSync(raw, { loader: 'tsx', jsxFactory: 'h', jsxFragment: 'Fragment', charset: 'utf8', }); return code; } catch ({ errors }) { const err = new Error() as any; const e = errors[0]; err.filename = state.filename; const text = readFileSync(state.filename).toString(); const start = positionAt(location.start, text); start.line += e.location.line; start.character += e.location.column + 1; err.start = { line: start.line, column: start.character }; const end = { ...start }; end.character += e.location.length; const frame = codeFrameColumns(text, { start: { line: start.line, column: start.character }, end: { line: end.line, column: end.character }, }); err.frame = frame; err.message = e.text; parseError(compileOptions.logging, err); return null; } } interface CompileResult { script: string; createCollection?: string; } interface CodegenState { components: Components; css: string[]; filename: string; fileID: string; markers: { insideMarkdown: boolean | Record; }; declarations: Set; exportStatements: Set; importStatements: Set; customElementCandidates: Map; } /** Compile/prepare Astro frontmatter scripts */ function compileModule(ast: Ast, module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult { const { astroConfig } = compileOptions; const { filename } = state; const componentImports: ImportDeclaration[] = []; const componentProps: VariableDeclarator[] = []; const componentExports: ExportNamedDeclaration[] = []; const contentImports = new Map(); let script = ''; let propsStatement = ''; let contentCode = ''; // code for handling Astro.fetchContent(), if any; let createCollection = ''; // function for executing collection if (module) { const parseOptions: babelParser.ParserOptions = { sourceType: 'module', plugins: ['jsx', 'typescript', 'topLevelAwait', 'throwExpressions'], }; let parseResult; try { parseResult = babelParser.parse(module.content, parseOptions); } catch (err) { const location = { start: err.loc }; const frame = codeFrameColumns(module.content, location); err.frame = frame; err.filename = state.filename; err.start = err.loc; throw err; } const program = parseResult.program; const { body } = program; let i = body.length; while (--i >= 0) { const node = body[i]; switch (node.type) { // case 'ExportAllDeclaration': // case 'ExportDefaultDeclaration': case 'ExportNamedDeclaration': { if (!node.declaration) break; if (node.declaration.type === 'VariableDeclaration') { // case 1: prop (export let title) const declaration = node.declaration.declarations[0]; if ((declaration.id as Identifier).name === '__layout' || (declaration.id as Identifier).name === '__content') { componentExports.push(node); } else { componentProps.push(declaration); } } else if (node.declaration.type === 'FunctionDeclaration') { // case 2: createCollection (export async function) if (!node.declaration.id || node.declaration.id.name !== 'createCollection') break; createCollection = module.content.substring(node.start || 0, node.end || 0); } body.splice(i, 1); break; } case 'FunctionDeclaration': { if (node.id?.name) { state.declarations.add(node.id?.name); } break; } case 'ImportDeclaration': { componentImports.push(node); body.splice(i, 1); // remove node break; } case 'VariableDeclaration': { for (const declaration of node.declarations) { // only select Astro.fetchContent() calls for more processing, // otherwise just push name to declarations if (!isFetchContent(declaration)) { if (declaration.id.type === 'Identifier') { state.declarations.add(declaration.id.name); } continue; } // remove node body.splice(i, 1); // a bit of munging let { id, init } = declaration; if (!id || !init || id.type !== 'Identifier') continue; if (init.type === 'AwaitExpression') { init = init.argument; const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename); warn(compileOptions.logging, shortname, yellow('awaiting Astro.fetchContent() not necessary')); } if (init.type !== 'CallExpression') continue; // gather data const namespace = id.name; if ((init as any).arguments[0].type !== 'StringLiteral') { throw new Error(`[Astro.fetchContent] Only string literals allowed, ex: \`Astro.fetchContent('./post/*.md')\`\n ${state.filename}`); } const spec = (init as any).arguments[0].value; if (typeof spec === 'string') contentImports.set(namespace, { spec, declarator: node.kind }); } break; } } } for (const componentImport of componentImports) { const importUrl = componentImport.source.value; if (nodeBuiltinsSet.has(importUrl)) { throw new Error(`Node builtins must be prefixed with 'node:'. Use node:${importUrl} instead.`); } for (const specifier of componentImport.specifiers) { const componentName = specifier.local.name; state.components.set(componentName, { importSpecifier: specifier, url: importUrl, }); } const { start, end } = componentImport; if (ast.meta.features & FEATURE_CUSTOM_ELEMENT && componentImport.specifiers.length === 0) { // Add possible custom element, but only if the AST says there are custom elements. const moduleImportName = camelCase(importUrl + 'Module'); state.importStatements.add(`import * as ${moduleImportName} from '${importUrl}';\n`); state.customElementCandidates.set(moduleImportName, getComponentUrl(astroConfig, importUrl, pathToFileURL(filename))); } else { state.importStatements.add(module.content.slice(start || undefined, end || undefined)); } } // TODO: actually expose componentExports other than __layout and __content for (const componentImport of componentExports) { const { start, end } = componentImport; state.exportStatements.add(module.content.slice(start || undefined, end || undefined)); } if (componentProps.length > 0) { const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename); const props = componentProps.map((prop) => (prop.id as Identifier)?.name).filter((v) => v); warn( compileOptions.logging, shortname, yellow(`\nDefining props with "export" has been removed! Please see https://github.com/snowpackjs/astro/blob/main/packages/astro/CHANGELOG.md#0150 Please update your code to use: const { ${props.join(', ')} } = Astro.props;\n`) ); } // handle createCollection, if any if (createCollection) { const ast = babelParser.parse(createCollection, { sourceType: 'module', }); traverse(ast, { enter({ node }) { switch (node.type) { case 'VariableDeclaration': { for (const declaration of node.declarations) { // only select Astro.fetchContent() calls here. this utility filters those out for us. if (!isFetchContent(declaration)) continue; // a bit of munging let { id, init } = declaration; if (!id || !init || id.type !== 'Identifier') continue; if (init.type === 'AwaitExpression') { init = init.argument; const shortname = path.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename); warn(compileOptions.logging, shortname, yellow('awaiting Astro.fetchContent() not necessary')); } if (init.type !== 'CallExpression') continue; // gather data const namespace = id.name; if ((init as any).arguments[0].type !== 'StringLiteral') { throw new Error(`[Astro.fetchContent] Only string literals allowed, ex: \`Astro.fetchContent('./post/*.md')\`\n ${state.filename}`); } const spec = (init as any).arguments[0].value; if (typeof spec !== 'string') break; const globResult = fetchContent(spec, { namespace, filename: state.filename }); let imports = ''; for (const importStatement of globResult.imports) { imports += importStatement + '\n'; } createCollection = imports + createCollection.substring(0, declaration.start || 0) + globResult.code + createCollection.substring(declaration.end || 0); } break; } } }, }); } // Astro.fetchContent() for (const [namespace, { spec }] of contentImports.entries()) { const globResult = fetchContent(spec, { namespace, filename: state.filename }); for (const importStatement of globResult.imports) { state.importStatements.add(importStatement); } contentCode += globResult.code; } script = propsStatement + contentCode + babelGenerator(program).code; const location = { start: module.start, end: module.end }; let transpiledScript = transpileExpressionSafe(script, { state, compileOptions, location }); if (transpiledScript === null) throw new Error(`Unable to compile script`); script = transpiledScript; } return { script, createCollection: createCollection || undefined, }; } /** Compile styles */ function compileCss(style: Style, state: CodegenState) { walk(style, { enter(node: TemplateNode) { if (node.type === 'Style') { state.css.push(node.content.styles); // if multiple