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; ``` +### Slots + +`.astro` files use the [``](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 `` element as a placeholder for markup which will be passed from outside of the component. + +```astro + +
+ +
+ + + +

Hello world!

+
+``` + +Slots are especially powerful when using **named slots**. Rather than a single `` 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 + +
+
+ +
+ +
+ + +
+ +
+ +
+
+ + + +

Hello world!

+

Lorem ipsum ...

+ +
+``` + +Slots also have the ability to render **fallback content**. When there are no matching children passed to a ``, a `` element will be replaced with its own children. + +```astro + +
+ +

I will render when this slot does not have any children!

+
+
+``` + ### 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 | `` | `` | | Expression Syntax | `{}` | `{}` | -| Spread Attributes | `{...props}` | `{...props}` | +| Spread Attributes | `{...props}` | `{...props}` +| +| Children | `` (with named slot support) | `children` +| | Boolean Attributes | `autocomplete` === `autocomplete={true}` | `autocomplete` === `autocomplete={true}` | | Inline Functions | `{items.map(item =>
  • {item}
  • )}` | `{items.map(item =>
  • {item}
  • )}` | | 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[]) => { + const $slots: Record = { + 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[]) => { + 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 @@ + +
    +
    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 @@ +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    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'; +--- + + + + + + + + A + Default + + + 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'] +--- + + + + + + + + A + B + C + Default + + + 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'; +--- + + + + + + +
    + +
    + +
    + + 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'; +--- + + + + + + +
    + +
    + + 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'; +--- + + + + + + + + A + B + C + Default + + + 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'; +--- + + + + + + + + A + B + C + + + 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; });