Fix {...spread} props (#522)

* fix(#521): allow spread props

* chore: add spread prop tests

* fix: falsy expressions should only be skipped in 'Expression' case

* fix: support primitives in expressions (objects, arrays)
This commit is contained in:
Nate Moore 2021-06-24 17:41:10 -05:00 committed by GitHub
parent 02ecaf3d33
commit 47ac2ccd17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 107 additions and 32 deletions

View file

@ -0,0 +1,6 @@
---
'astro': patch
'@astrojs/parser': patch
---
Fix #521, allowing `{...spread}` props to work again

View file

@ -240,7 +240,6 @@ export const parse_expression_at = (source: string, index: number): Expression =
return expression;
};
// @ts-ignore
export default function read_expression(parser: Parser) {
try {
const expression = parse_expression_at(parser.template, parser.index);

View file

@ -349,8 +349,7 @@ function read_attribute(parser: Parser, unique_names: Set<string>) {
parser.allow_whitespace();
if (parser.eat('...')) {
const { expression } = read_expression(parser);
const expression = read_expression(parser);
parser.allow_whitespace();
parser.eat('}', true);

View file

@ -1,4 +1,4 @@
import type { Ast, Script, Style, TemplateNode } from '@astrojs/parser';
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';
@ -17,11 +17,10 @@ import { error, warn, parseError } from '../../logger.js';
import { fetchContent } from './content.js';
import { isFetchContent } from './utils.js';
import { yellow } from 'kleur/colors';
import { isComponentTag } from '../utils';
import { isComponentTag, positionAt } from '../utils.js';
import { renderMarkdown } from '@astrojs/markdown-support';
import { transform } from '../transform/index.js';
import { PRISM_IMPORT } from '../transform/prism.js';
import { positionAt } from '../utils';
import { nodeBuiltinsSet } from '../../node_builtins.js';
import { readFileSync } from 'fs';
@ -34,9 +33,10 @@ const { transformSync } = esbuild;
interface Attribute {
start: number;
end: number;
type: 'Attribute';
type: 'Attribute'|'Spread';
name: string;
value: TemplateNode[] | boolean;
expression?: Expression;
}
interface CodeGenOptions {
@ -46,9 +46,16 @@ interface CodeGenOptions {
}
/** Retrieve attributes from TemplateNode */
function getAttributes(attrs: Attribute[]): Record<string, string> {
async function getAttributes(attrs: Attribute[], state: CodegenState, compileOptions: CompileOptions): Promise<Record<string, string>> {
let result: Record<string, string> = {};
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;
@ -83,18 +90,18 @@ function getAttributes(attrs: Attribute[]): Record<string, string> {
}
switch (val.type) {
case 'MustacheTag': {
// FIXME: this won't work when JSX element can appear in attributes (rare but possible).
const codeChunks = val.expression.codeChunks[0];
if (codeChunks) {
result[attr.name] = '(' + codeChunks + ')';
} else {
throw new Error(`Parse error: ${attr.name}={}`); // if bad codeChunk, throw error
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}`);
}
@ -126,7 +133,11 @@ function getTextFromAttribute(attr: any): string {
function generateAttributes(attrs: Record<string, string>): string {
let result = '{';
for (const [key, val] of Object.entries(attrs)) {
result += JSON.stringify(key) + ':' + val + ',';
if (key.startsWith('...')) {
result += key + ',';
} else {
result += JSON.stringify(key) + ':' + val + ',';
}
}
return result + '}';
}
@ -172,11 +183,37 @@ function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentI
};
}
/**
* 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 compileExpressionSafe(
function transpileExpressionSafe(
raw: string,
{ state, compileOptions, location }: { state: CodegenState; compileOptions: CompileOptions; location: { start: number; end: number } }
): string | null {
// We have to wrap `raw` with parens to support primitives (objects, arrays)!
raw = `(${raw})`;
try {
let { code } = transformSync(raw, {
loader: 'tsx',
@ -497,21 +534,12 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
async enter(node: TemplateNode, parent: TemplateNode) {
switch (node.type) {
case 'Expression': {
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++];
}
let code = await compileExpression(node as Expression, state, compileOptions);
if (FALSY_EXPRESSIONS.has(code)) {
this.skip();
break;
}
const location = { start: node.start, end: node.end };
// TODO Do we need to compile this now, or should we compile the entire module at the end?
let code = compileExpressionSafe(raw, { state, compileOptions, location });
if (code === null) throw new Error(`Unable to compile expression`);
code = code.trim().replace(/\;$/, '');
if (!FALSY_EXPRESSIONS.has(code)) {
if (code !== '') {
if (state.markers.insideMarkdown) {
buffers[curr] += `{${code}}`;
} else {
@ -566,7 +594,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
throw new Error('AHHHH');
}
try {
const attributes = getAttributes(node.attributes);
const attributes = await getAttributes(node.attributes, state, compileOptions);
buffers.out += buffers.out === '' ? '' : ',';
@ -623,7 +651,8 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
}
return;
}
case 'Attribute': {
case 'Attribute':
case 'Spread': {
this.skip();
return;
}
@ -671,6 +700,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
}
case 'Text':
case 'Attribute':
case 'Spread':
case 'Comment':
case 'Expression':
case 'MustacheTag':

View file

@ -68,4 +68,31 @@ Basics('Allows forward-slashes in mustache tags (#407)', async ({ runtime }) =>
assert.equal($('a[href="/post/three"]').length, 1);
});
Basics('Allows spread attributes (#521)', async ({ runtime }) => {
const result = await runtime.load('/spread');
const html = result.contents;
const $ = doc(html);
assert.equal($('#spread-leading').length, 1);
assert.equal($('#spread-leading').attr('a'), '0');
assert.equal($('#spread-leading').attr('b'), '1');
assert.equal($('#spread-leading').attr('c'), '2');
assert.equal($('#spread-trailing').length, 1);
assert.equal($('#spread-trailing').attr('a'), '0');
assert.equal($('#spread-trailing').attr('b'), '1');
assert.equal($('#spread-trailing').attr('c'), '2');
});
Basics('Allows spread attributes with TypeScript (#521)', async ({ runtime }) => {
const result = await runtime.load('/spread');
const html = result.contents;
const $ = doc(html);
assert.equal($('#spread-ts').length, 1);
assert.equal($('#spread-ts').attr('a'), '0');
assert.equal($('#spread-ts').attr('b'), '1');
assert.equal($('#spread-ts').attr('c'), '2');
});
Basics.run();

View file

@ -0,0 +1,14 @@
---
const spread = { a: 0, b: 1, c: 2 };
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<div {...spread} id="spread-leading" />
<div id="spread-trailing" {...spread} />
<div id="spread-ts" {...(spread as any)} />
</body>
</html>