aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Nate Moore <natemoo-re@users.noreply.github.com> 2021-07-12 13:07:39 -0500
committerGravatar GitHub <noreply@github.com> 2021-07-12 13:07:39 -0500
commit8f4562afbe1b2cfdfd61dfaef3ad2666b58591fb (patch)
tree5c32f575a2b376ba918f3d7fa3b550cc7986940d
parentf62973b5ca21facbd7582b4ca32bba5e3aae0da3 (diff)
downloadastro-8f4562afbe1b2cfdfd61dfaef3ad2666b58591fb.tar.gz
astro-8f4562afbe1b2cfdfd61dfaef3ad2666b58591fb.tar.zst
astro-8f4562afbe1b2cfdfd61dfaef3ad2666b58591fb.zip
Add support for named slots (#661)
* feat: support named slots, slot fallback content * docs: document slots * chore: add changeset * fix: build errors * chore: prefer `patch` version
-rw-r--r--.changeset/fair-flowers-sleep.md7
-rw-r--r--docs/core-concepts/astro-components.md63
-rw-r--r--packages/astro/src/compiler/codegen/index.ts28
-rw-r--r--packages/astro/src/internal/__astro_component.ts49
-rw-r--r--packages/astro/src/internal/__astro_slot.ts15
-rw-r--r--packages/astro/src/internal/renderer-astro.ts8
-rw-r--r--packages/astro/test/astro-slots.test.js79
-rw-r--r--packages/astro/test/fixtures/astro-slots/snowpack.config.json3
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/components/Fallback.astro3
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/components/Slotted.astro15
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/pages/component.astro17
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/pages/dynamic.astro19
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/pages/fallback-override.astro16
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/pages/fallback.astro14
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/pages/index.astro17
-rw-r--r--packages/astro/test/fixtures/astro-slots/src/pages/multiple.astro16
-rw-r--r--packages/markdown-support/src/rehype-collect-headers.ts2
-rw-r--r--packages/markdown-support/src/rehype-expressions.ts2
18 files changed, 346 insertions, 27 deletions
diff --git a/.changeset/fair-flowers-sleep.md b/.changeset/fair-flowers-sleep.md
new file mode 100644
index 000000000..ef6b8db43
--- /dev/null
+++ b/.changeset/fair-flowers-sleep.md
@@ -0,0 +1,7 @@
+---
+'astro': patch
+---
+
+Improve slot support, adding support for named slots and fallback content within `slot` elements.
+
+See the new [Slots documentation](https://github.com/snowpackjs/astro/blob/main/docs/core-concepts/astro-components.md#slots) for more information.
diff --git a/docs/core-concepts/astro-components.md b/docs/core-concepts/astro-components.md
index 5cf9f4911..1be0d631e 100644
--- a/docs/core-concepts/astro-components.md
+++ b/docs/core-concepts/astro-components.md
@@ -120,6 +120,62 @@ const { greeting = 'Hello', name } = Astro.props;
</main>
```
+### Slots
+
+`.astro` files use the [`<slot>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot) element to enable component composition. Coming from React, this is the same concept as `children`. You can think of the `<slot>` element as a placeholder for markup which will be passed from outside of the component.
+
+```astro
+<!-- MyComponent.astro -->
+<div id="my-component">
+ <slot /> <!-- children will go here -->
+</div>
+
+<!-- Usage -->
+<MyComponent>
+ <h1>Hello world!</h1>
+</MyComponent>
+```
+
+Slots are especially powerful when using **named slots**. Rather than a single `<slot>` element which renders _all_ children, named slots allow you to specify where certain children should be placed.
+
+> **Note** The `slot` attribute is not restricted to plain HTML, components can use `slot` as well!
+
+```astro
+<!-- MyComponent.astro -->
+<div id="my-component">
+ <header>
+ <slot name="header" /> <!-- children with the `slot="header"` attribute will go here -->
+ </header>
+
+ <main>
+ <!-- children without a `slot` (or with the `slot="default"`) attribute will go here -->
+ <slot />
+ </main>
+
+ <footer>
+ <slot name="footer"> <!-- children with the `slot="footer"` attribute will go here -->
+ </footer>
+</div>
+
+<!-- Usage -->
+<MyComponent>
+ <h1 slot="header">Hello world!</h1>
+ <p>Lorem ipsum ...</p>
+ <FooterComponent slot="footer" />
+</MyComponent>
+```
+
+Slots also have the ability to render **fallback content**. When there are no matching children passed to a `<slot>`, a `<slot>` element will be replaced with its own children.
+
+```astro
+<!-- MyComponent.astro -->
+<div id="my-component">
+ <slot>
+ <h1>I will render when this slot does not have any children!</h1>
+ </slot>
+</div>
+```
+
### Fragments
At the top-level of an `.astro` file, you may render any number of elements.
@@ -154,7 +210,10 @@ Inside of an expression, you must wrap multiple elements in a Fragment. Fragment
| File extension | `.astro` | `.jsx` or `.tsx` |
| User-Defined Components | `<Capitalized>` | `<Capitalized>` |
| Expression Syntax | `{}` | `{}` |
-| Spread Attributes | `{...props}` | `{...props}` |
+| Spread Attributes | `{...props}` | `{...props}`
+|
+| Children | `<slot>` (with named slot support) | `children`
+|
| Boolean Attributes | `autocomplete` === `autocomplete={true}` | `autocomplete` === `autocomplete={true}` |
| Inline Functions | `{items.map(item => <li>{item}</li>)}` | `{items.map(item => <li>{item}</li>)}` |
| IDE Support | WIP - [VS Code][code-ext] | Phenomenal |
@@ -199,6 +258,4 @@ import thumbnailSrc from './thumbnail.png';
If you’d prefer to organize assets alongside Astro components, you may import the file in JavaScript inside the component script. This works as intended but this makes `thumbnail.png` harder to reference in other parts of your app, as its final URL isn’t easily-predictable (unlike assets in `public/*`, where the final URL is guaranteed to never change).
-### TODO: Composition (Slots)
-
[code-ext]: https://marketplace.visualstudio.com/items?itemName=astro-build.astro-vscode
diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts
index 36f5a0c56..f264b5878 100644
--- a/packages/astro/src/compiler/codegen/index.ts
+++ b/packages/astro/src/compiler/codegen/index.ts
@@ -679,7 +679,8 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
buffers.out += buffers.out === '' ? '' : ',';
if (node.type === 'Slot') {
- buffers[curr] += `(children`;
+ state.importStatements.add(`import { __astro_slot } from 'astro/dist/internal/__astro_slot.js';`);
+ buffers[curr] += `h(__astro_slot, ${attributes ? generateAttributes(attributes) : 'null'}, children`;
paren++;
return;
}
@@ -687,6 +688,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
+ if (attributes.slot) {
+ state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
+ buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
+ paren++;
+ }
buffers[curr] += `h("${name}", ${attributes ? generateAttributes(attributes) : 'null'}`;
paren++;
return;
@@ -712,8 +718,13 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
await pushMarkdownToBuffer();
}
- paren++;
+ if (attributes.slot) {
+ state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
+ buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
+ paren++;
+ }
buffers[curr] += `h(${componentName}, ${attributes ? generateAttributes(attributes) : 'null'}`;
+ paren++;
return;
} else if (!state.declarations.has(componentName) && !componentInfo && !isCustomElementTag(componentName)) {
throw new Error(`Unable to render "${componentName}" because it is undefined\n ${state.filename}`);
@@ -741,6 +752,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
await pushMarkdownToBuffer();
}
+ if (attributes.slot) {
+ state.importStatements.add(`import { __astro_slot_content } from 'astro/dist/internal/__astro_slot.js';`);
+ buffers[curr] += `h(__astro_slot_content, { name: ${attributes.slot} },`;
+ paren++;
+ }
paren++;
buffers[curr] += `h(${wrapper}, ${attributes ? generateAttributes(attributes) : 'null'}`;
} catch (err) {
@@ -817,6 +833,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
if (curr === 'markdown') {
await pushMarkdownToBuffer();
}
+ if (node.attributes.find((attr: any) => attr.name === 'slot')) {
+ buffers.out += ')';
+ paren--;
+ }
if (paren !== -1) {
buffers.out += ')';
paren--;
@@ -840,6 +860,10 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile
return;
}
}
+ if (node.attributes.find((attr: any) => attr.name === 'slot')) {
+ buffers.out += ')';
+ paren--;
+ }
if (paren !== -1) {
buffers.out += ')';
paren--;
diff --git a/packages/astro/src/internal/__astro_component.ts b/packages/astro/src/internal/__astro_component.ts
index 873c1b7d4..ff1d2ff54 100644
--- a/packages/astro/src/internal/__astro_component.ts
+++ b/packages/astro/src/internal/__astro_component.ts
@@ -2,7 +2,6 @@ import type { Renderer, AstroComponentMetadata } from '../@types/astro';
import hash from 'shorthash';
import { valueToEstree, Value } from 'estree-util-value-to-estree';
import { generate } from 'astring';
-import * as astro from './renderer-astro';
import * as astroHtml from './renderer-html';
// A more robust version alternative to `JSON.stringify` that can handle most values
@@ -16,13 +15,6 @@ export interface RendererInstance {
hydrationPolyfills: string[];
}
-const astroRendererInstance: RendererInstance = {
- source: '',
- renderer: astro as Renderer,
- polyfills: [],
- hydrationPolyfills: [],
-};
-
const astroHtmlRendererInstance: RendererInstance = {
source: '',
renderer: astroHtml as Renderer,
@@ -33,7 +25,7 @@ const astroHtmlRendererInstance: RendererInstance = {
let rendererInstances: RendererInstance[] = [];
export function setRenderers(_rendererInstances: RendererInstance[]) {
- rendererInstances = [astroRendererInstance].concat(_rendererInstances);
+ rendererInstances = ([] as RendererInstance[]).concat(_rendererInstances);
}
function isCustomElementTag(name: string | Function) {
@@ -121,15 +113,48 @@ const getComponentName = (Component: any, componentProps: any) => {
}
};
-export const __astro_component = (Component: any, metadata: AstroComponentMetadata = {} as any) => {
+const prepareSlottedChildren = (children: string|Record<any, any>[]) => {
+ const $slots: Record<string, string> = {
+ default: ''
+ };
+ for (const child of children) {
+ if (typeof child === 'string') {
+ $slots.default += child;
+ } else if (typeof child === 'object' && child['$slot']) {
+ if (!$slots[child['$slot']]) $slots[child['$slot']] = '';
+ $slots[child['$slot']] += child.children.join('').replace(new RegExp(`slot="${child['$slot']}"\s*`, ''));
+ }
+ }
+
+ return { $slots };
+}
+
+const removeSlottedChildren = (_children: string|Record<any, any>[]) => {
+ let children = '';
+ for (const child of _children) {
+ if (typeof child === 'string') {
+ children += child;
+ } else if (typeof child === 'object' && child['$slot']) {
+ children += child.children.join('');
+ }
+ }
+
+ return children;
+}
+
+/** The main wrapper for any components in Astro files */
+export function __astro_component(Component: any, metadata: AstroComponentMetadata = {} as any) {
if (Component == null) {
throw new Error(`Unable to render ${metadata.displayName} because it is ${Component}!\nDid you forget to import the component or is it possible there is a typo?`);
} else if (typeof Component === 'string' && !isCustomElementTag(Component)) {
throw new Error(`Astro is unable to render ${metadata.displayName}!\nIs there a renderer to handle this type of component defined in your Astro config?`);
}
- return async (props: any, ..._children: string[]) => {
- const children = _children.join('\n');
+ return async function __astro_component_internal(props: any, ..._children: any[]) {
+ if (Component.isAstroComponent) {
+ return Component.__render(props, prepareSlottedChildren(_children));
+ }
+ const children = removeSlottedChildren(_children);
let instance = await resolveRenderer(Component, props, children);
if (!instance) {
diff --git a/packages/astro/src/internal/__astro_slot.ts b/packages/astro/src/internal/__astro_slot.ts
new file mode 100644
index 000000000..c049e82bc
--- /dev/null
+++ b/packages/astro/src/internal/__astro_slot.ts
@@ -0,0 +1,15 @@
+/** */
+export function __astro_slot_content({ name }: { name: string}, ...children: any[]) {
+ return { '$slot': name, children };
+}
+
+export const __astro_slot = ({ name = 'default' }: { name: string}, _children: any, ...fallback: string[]) => {
+ if (name === 'default' && typeof _children === 'string') {
+ return _children ? _children : fallback;
+ }
+ if (!_children.$slots) {
+ throw new Error(`__astro_slot encountered an unexpected child:\n${JSON.stringify(_children)}`);
+ }
+ const children = _children.$slots[name];
+ return children ? children : fallback;
+};
diff --git a/packages/astro/src/internal/renderer-astro.ts b/packages/astro/src/internal/renderer-astro.ts
deleted file mode 100644
index 10af2a5a9..000000000
--- a/packages/astro/src/internal/renderer-astro.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export function check(Component: any) {
- return Component.isAstroComponent;
-}
-
-export async function renderToStaticMarkup(Component: any, props: any, children: string) {
- const html = await Component.__render(props, children);
- return { html };
-}
diff --git a/packages/astro/test/astro-slots.test.js b/packages/astro/test/astro-slots.test.js
new file mode 100644
index 000000000..343244910
--- /dev/null
+++ b/packages/astro/test/astro-slots.test.js
@@ -0,0 +1,79 @@
+import { suite } from 'uvu';
+import * as assert from 'uvu/assert';
+import { doc } from './test-utils.js';
+import { setup, setupBuild } from './helpers.js';
+
+const Slots = suite('Slot test');
+
+setup(Slots, './fixtures/astro-slots', {
+ runtimeOptions: {
+ mode: 'development',
+ },
+});
+setupBuild(Slots, './fixtures/astro-slots');
+
+Slots('Basic named slots work', async ({ runtime }) => {
+ const result = await runtime.load('/');
+ if (result.error) throw new Error(result.error);
+
+ const $ = doc(result.contents);
+
+ assert.equal($('#a').text(), 'A');
+ assert.equal($('#b').text(), 'B');
+ assert.equal($('#c').text(), 'C');
+ assert.equal($('#default').text(), 'Default');
+});
+
+Slots('Dynamic named slots work', async ({ runtime }) => {
+ const result = await runtime.load('/dynamic');
+ if (result.error) throw new Error(result.error);
+
+ const $ = doc(result.contents);
+
+ assert.equal($('#a').text(), 'A');
+ assert.equal($('#b').text(), 'B');
+ assert.equal($('#c').text(), 'C');
+ assert.equal($('#default').text(), 'Default');
+});
+
+Slots('Slots render fallback content by default', async ({ runtime }) => {
+ const result = await runtime.load('/fallback');
+ if (result.error) throw new Error(result.error);
+
+ const $ = doc(result.contents);
+
+ assert.equal($('#default').length, 1);
+});
+
+Slots('Slots override fallback content', async ({ runtime }) => {
+ const result = await runtime.load('/fallback-override');
+ if (result.error) throw new Error(result.error);
+
+ const $ = doc(result.contents);
+
+ assert.equal($('#override').length, 1);
+});
+
+Slots('Slots work with multiple elements', async ({ runtime }) => {
+ const result = await runtime.load('/multiple');
+ if (result.error) throw new Error(result.error);
+
+ const $ = doc(result.contents);
+
+ assert.equal($('#a').text(), 'ABC');
+});
+
+
+Slots('Slots work on Components', async ({ runtime }) => {
+ const result = await runtime.load('/component');
+ if (result.error) throw new Error(result.error);
+
+ const $ = doc(result.contents);
+
+ assert.equal($('#a').length, 1);
+ assert.equal($('#a').children('astro-component').length, 1, 'Slotted component into #a');
+ assert.equal($('#default').children('astro-component').length, 1, 'Slotted component into default slot');
+});
+
+
+Slots.run();
diff --git a/packages/astro/test/fixtures/astro-slots/snowpack.config.json b/packages/astro/test/fixtures/astro-slots/snowpack.config.json
new file mode 100644
index 000000000..8f034781d
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/snowpack.config.json
@@ -0,0 +1,3 @@
+{
+ "workspaceRoot": "../../../../../"
+}
diff --git a/packages/astro/test/fixtures/astro-slots/src/components/Fallback.astro b/packages/astro/test/fixtures/astro-slots/src/components/Fallback.astro
new file mode 100644
index 000000000..2f4e1cb3a
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/components/Fallback.astro
@@ -0,0 +1,3 @@
+<slot>
+ <div id="default"></div>
+</slot>
diff --git a/packages/astro/test/fixtures/astro-slots/src/components/Slotted.astro b/packages/astro/test/fixtures/astro-slots/src/components/Slotted.astro
new file mode 100644
index 000000000..21f959ebb
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/components/Slotted.astro
@@ -0,0 +1,15 @@
+<div id="a">
+ <slot name="a" />
+</div>
+
+<div id="b">
+ <slot name="b" />
+</div>
+
+<div id="c">
+ <slot name="c" />
+</div>
+
+<div id="default">
+ <slot />
+</div>
diff --git a/packages/astro/test/fixtures/astro-slots/src/pages/component.astro b/packages/astro/test/fixtures/astro-slots/src/pages/component.astro
new file mode 100644
index 000000000..e01aa81dd
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/pages/component.astro
@@ -0,0 +1,17 @@
+---
+import Slotted from '../components/Slotted.astro';
+
+const Component = 'astro-component';
+---
+
+<html>
+ <head>
+ <!-- Head Stuff -->
+ </head>
+ <body>
+ <Slotted>
+ <Component slot="a">A</Component>
+ <Component>Default</Component>
+ </Slotted>
+ </body>
+</html>
diff --git a/packages/astro/test/fixtures/astro-slots/src/pages/dynamic.astro b/packages/astro/test/fixtures/astro-slots/src/pages/dynamic.astro
new file mode 100644
index 000000000..a030a86b3
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/pages/dynamic.astro
@@ -0,0 +1,19 @@
+---
+import Slotted from '../components/Slotted.astro';
+
+const slots = ['a', 'b', 'c']
+---
+
+<html>
+ <head>
+ <!-- Head Stuff -->
+ </head>
+ <body>
+ <Slotted>
+ <span slot={slots[0]}>A</span>
+ <span slot={slots[1]}>B</span>
+ <span slot={slots[2]}>C</span>
+ <span>Default</span>
+ </Slotted>
+ </body>
+</html>
diff --git a/packages/astro/test/fixtures/astro-slots/src/pages/fallback-override.astro b/packages/astro/test/fixtures/astro-slots/src/pages/fallback-override.astro
new file mode 100644
index 000000000..76389c36c
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/pages/fallback-override.astro
@@ -0,0 +1,16 @@
+---
+import Fallback from '../components/Fallback.astro';
+---
+
+<html>
+ <head>
+ <!-- Head Stuff -->
+ </head>
+ <body>
+ <div id="fallback">
+ <Fallback>
+ <div id="override" />
+ </Fallback>
+ </div>
+ </body>
+</html>
diff --git a/packages/astro/test/fixtures/astro-slots/src/pages/fallback.astro b/packages/astro/test/fixtures/astro-slots/src/pages/fallback.astro
new file mode 100644
index 000000000..88aba06e9
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/pages/fallback.astro
@@ -0,0 +1,14 @@
+---
+import Fallback from '../components/Fallback.astro';
+---
+
+<html>
+ <head>
+ <!-- Head Stuff -->
+ </head>
+ <body>
+ <div id="fallback">
+ <Fallback />
+ </div>
+ </body>
+</html>
diff --git a/packages/astro/test/fixtures/astro-slots/src/pages/index.astro b/packages/astro/test/fixtures/astro-slots/src/pages/index.astro
new file mode 100644
index 000000000..330361ab6
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/pages/index.astro
@@ -0,0 +1,17 @@
+---
+import Slotted from '../components/Slotted.astro';
+---
+
+<html>
+ <head>
+ <!-- Head Stuff -->
+ </head>
+ <body>
+ <Slotted>
+ <span slot="a">A</span>
+ <span slot="b">B</span>
+ <span slot="c">C</span>
+ <span>Default</span>
+ </Slotted>
+ </body>
+</html>
diff --git a/packages/astro/test/fixtures/astro-slots/src/pages/multiple.astro b/packages/astro/test/fixtures/astro-slots/src/pages/multiple.astro
new file mode 100644
index 000000000..901d228f6
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-slots/src/pages/multiple.astro
@@ -0,0 +1,16 @@
+---
+import Slotted from '../components/Slotted.astro';
+---
+
+<html>
+ <head>
+ <!-- Head Stuff -->
+ </head>
+ <body>
+ <Slotted>
+ <span slot="a">A</span>
+ <span slot="a">B</span>
+ <span slot="a">C</span>
+ </Slotted>
+ </body>
+</html>
diff --git a/packages/markdown-support/src/rehype-collect-headers.ts b/packages/markdown-support/src/rehype-collect-headers.ts
index de9b78692..78774e494 100644
--- a/packages/markdown-support/src/rehype-collect-headers.ts
+++ b/packages/markdown-support/src/rehype-collect-headers.ts
@@ -16,7 +16,7 @@ export default function createCollectHeaders() {
let text = '';
visit(node, 'text', (child) => {
- text += child.value;
+ text += (child as any).value;
});
let slug = node.properties.id || slugger.slug(text);
diff --git a/packages/markdown-support/src/rehype-expressions.ts b/packages/markdown-support/src/rehype-expressions.ts
index 016d36aaf..d296c2afe 100644
--- a/packages/markdown-support/src/rehype-expressions.ts
+++ b/packages/markdown-support/src/rehype-expressions.ts
@@ -4,7 +4,7 @@ export default function rehypeExpressions(): any {
return function (node: any): any {
return map(node, (child) => {
if (child.type === 'mdxTextExpression') {
- return { type: 'text', value: `{${child.value}}` };
+ return { type: 'text', value: `{${(child as any).value}}` };
}
return child;
});