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:
parent
02ecaf3d33
commit
47ac2ccd17
6 changed files with 107 additions and 32 deletions
6
.changeset/shy-cats-brake.md
Normal file
6
.changeset/shy-cats-brake.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
'@astrojs/parser': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix #521, allowing `{...spread}` props to work again
|
|
@ -240,7 +240,6 @@ export const parse_expression_at = (source: string, index: number): Expression =
|
||||||
return expression;
|
return expression;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
export default function read_expression(parser: Parser) {
|
export default function read_expression(parser: Parser) {
|
||||||
try {
|
try {
|
||||||
const expression = parse_expression_at(parser.template, parser.index);
|
const expression = parse_expression_at(parser.template, parser.index);
|
||||||
|
|
|
@ -349,8 +349,7 @@ function read_attribute(parser: Parser, unique_names: Set<string>) {
|
||||||
parser.allow_whitespace();
|
parser.allow_whitespace();
|
||||||
|
|
||||||
if (parser.eat('...')) {
|
if (parser.eat('...')) {
|
||||||
const { expression } = read_expression(parser);
|
const expression = read_expression(parser);
|
||||||
|
|
||||||
parser.allow_whitespace();
|
parser.allow_whitespace();
|
||||||
parser.eat('}', true);
|
parser.eat('}', true);
|
||||||
|
|
||||||
|
|
|
@ -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 { CompileOptions } from '../../@types/compiler';
|
||||||
import type { AstroConfig, AstroMarkdownOptions, TransformResult, ComponentInfo, Components } from '../../@types/astro';
|
import type { AstroConfig, AstroMarkdownOptions, TransformResult, ComponentInfo, Components } from '../../@types/astro';
|
||||||
import type { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier, ImportDefaultSpecifier } from '@babel/types';
|
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 { fetchContent } from './content.js';
|
||||||
import { isFetchContent } from './utils.js';
|
import { isFetchContent } from './utils.js';
|
||||||
import { yellow } from 'kleur/colors';
|
import { yellow } from 'kleur/colors';
|
||||||
import { isComponentTag } from '../utils';
|
import { isComponentTag, positionAt } from '../utils.js';
|
||||||
import { renderMarkdown } from '@astrojs/markdown-support';
|
import { renderMarkdown } from '@astrojs/markdown-support';
|
||||||
import { transform } from '../transform/index.js';
|
import { transform } from '../transform/index.js';
|
||||||
import { PRISM_IMPORT } from '../transform/prism.js';
|
import { PRISM_IMPORT } from '../transform/prism.js';
|
||||||
import { positionAt } from '../utils';
|
|
||||||
import { nodeBuiltinsSet } from '../../node_builtins.js';
|
import { nodeBuiltinsSet } from '../../node_builtins.js';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
@ -34,9 +33,10 @@ const { transformSync } = esbuild;
|
||||||
interface Attribute {
|
interface Attribute {
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
type: 'Attribute';
|
type: 'Attribute'|'Spread';
|
||||||
name: string;
|
name: string;
|
||||||
value: TemplateNode[] | boolean;
|
value: TemplateNode[] | boolean;
|
||||||
|
expression?: Expression;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CodeGenOptions {
|
interface CodeGenOptions {
|
||||||
|
@ -46,9 +46,16 @@ interface CodeGenOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieve attributes from TemplateNode */
|
/** 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> = {};
|
let result: Record<string, string> = {};
|
||||||
for (const attr of attrs) {
|
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) {
|
if (attr.value === true) {
|
||||||
result[attr.name] = JSON.stringify(attr.value);
|
result[attr.name] = JSON.stringify(attr.value);
|
||||||
continue;
|
continue;
|
||||||
|
@ -83,18 +90,18 @@ function getAttributes(attrs: Attribute[]): Record<string, string> {
|
||||||
}
|
}
|
||||||
switch (val.type) {
|
switch (val.type) {
|
||||||
case 'MustacheTag': {
|
case 'MustacheTag': {
|
||||||
// FIXME: this won't work when JSX element can appear in attributes (rare but possible).
|
const code = await compileExpression(val.expression, state, compileOptions);
|
||||||
const codeChunks = val.expression.codeChunks[0];
|
if (code) {
|
||||||
if (codeChunks) {
|
result[attr.name] = '(' + code + ')';
|
||||||
result[attr.name] = '(' + codeChunks + ')';
|
|
||||||
} else {
|
|
||||||
throw new Error(`Parse error: ${attr.name}={}`); // if bad codeChunk, throw error
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
case 'Text':
|
case 'Text':
|
||||||
result[attr.name] = JSON.stringify(getTextFromAttribute(val));
|
result[attr.name] = JSON.stringify(getTextFromAttribute(val));
|
||||||
continue;
|
continue;
|
||||||
|
case 'AttributeShorthand':
|
||||||
|
result[attr.name] = '(' + attr.name + ')';
|
||||||
|
continue;
|
||||||
default:
|
default:
|
||||||
throw new Error(`UNKNOWN: ${val.type}`);
|
throw new Error(`UNKNOWN: ${val.type}`);
|
||||||
}
|
}
|
||||||
|
@ -126,7 +133,11 @@ function getTextFromAttribute(attr: any): string {
|
||||||
function generateAttributes(attrs: Record<string, string>): string {
|
function generateAttributes(attrs: Record<string, string>): string {
|
||||||
let result = '{';
|
let result = '{';
|
||||||
for (const [key, val] of Object.entries(attrs)) {
|
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 + '}';
|
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) */
|
/** Evaluate expression (safely) */
|
||||||
function compileExpressionSafe(
|
function transpileExpressionSafe(
|
||||||
raw: string,
|
raw: string,
|
||||||
{ state, compileOptions, location }: { state: CodegenState; compileOptions: CompileOptions; location: { start: number; end: number } }
|
{ state, compileOptions, location }: { state: CodegenState; compileOptions: CompileOptions; location: { start: number; end: number } }
|
||||||
): string | null {
|
): string | null {
|
||||||
|
// We have to wrap `raw` with parens to support primitives (objects, arrays)!
|
||||||
|
raw = `(${raw})`;
|
||||||
try {
|
try {
|
||||||
let { code } = transformSync(raw, {
|
let { code } = transformSync(raw, {
|
||||||
loader: 'tsx',
|
loader: 'tsx',
|
||||||
|
@ -497,21 +534,12 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
||||||
async enter(node: TemplateNode, parent: TemplateNode) {
|
async enter(node: TemplateNode, parent: TemplateNode) {
|
||||||
switch (node.type) {
|
switch (node.type) {
|
||||||
case 'Expression': {
|
case 'Expression': {
|
||||||
const children: string[] = await Promise.all((node.children ?? []).map((child) => compileHtml(child, state, compileOptions)));
|
let code = await compileExpression(node as Expression, state, compileOptions);
|
||||||
let raw = '';
|
if (FALSY_EXPRESSIONS.has(code)) {
|
||||||
let nextChildIndex = 0;
|
this.skip();
|
||||||
for (const chunk of node.codeChunks) {
|
break;
|
||||||
raw += chunk;
|
|
||||||
if (nextChildIndex < children.length) {
|
|
||||||
raw += children[nextChildIndex++];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const location = { start: node.start, end: node.end };
|
if (code !== '') {
|
||||||
// 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 (state.markers.insideMarkdown) {
|
if (state.markers.insideMarkdown) {
|
||||||
buffers[curr] += `{${code}}`;
|
buffers[curr] += `{${code}}`;
|
||||||
} else {
|
} else {
|
||||||
|
@ -566,7 +594,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
||||||
throw new Error('AHHHH');
|
throw new Error('AHHHH');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const attributes = getAttributes(node.attributes);
|
const attributes = await getAttributes(node.attributes, state, compileOptions);
|
||||||
|
|
||||||
buffers.out += buffers.out === '' ? '' : ',';
|
buffers.out += buffers.out === '' ? '' : ',';
|
||||||
|
|
||||||
|
@ -623,7 +651,8 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'Attribute': {
|
case 'Attribute':
|
||||||
|
case 'Spread': {
|
||||||
this.skip();
|
this.skip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -671,6 +700,7 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
|
||||||
}
|
}
|
||||||
case 'Text':
|
case 'Text':
|
||||||
case 'Attribute':
|
case 'Attribute':
|
||||||
|
case 'Spread':
|
||||||
case 'Comment':
|
case 'Comment':
|
||||||
case 'Expression':
|
case 'Expression':
|
||||||
case 'MustacheTag':
|
case 'MustacheTag':
|
||||||
|
|
|
@ -68,4 +68,31 @@ Basics('Allows forward-slashes in mustache tags (#407)', async ({ runtime }) =>
|
||||||
assert.equal($('a[href="/post/three"]').length, 1);
|
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();
|
Basics.run();
|
||||||
|
|
14
packages/astro/test/fixtures/astro-basic/src/pages/spread.astro
vendored
Normal file
14
packages/astro/test/fixtures/astro-basic/src/pages/spread.astro
vendored
Normal 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>
|
Loading…
Add table
Reference in a new issue