@portabletext/astro 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ ---
2
+ import {
3
+ isPortableTextBlock,
4
+ isPortableTextListItemBlock,
5
+ isPortableTextToolkitList,
6
+ isPortableTextToolkitSpan,
7
+ isPortableTextToolkitTextNode,
8
+ nestLists,
9
+ buildMarksTree,
10
+ LIST_NEST_MODE_HTML,
11
+ } from "@portabletext/toolkit";
12
+
13
+ import type {
14
+ Component,
15
+ Context,
16
+ MissingComponentHandler,
17
+ NodeType,
18
+ PortableTextComponents,
19
+ PortableTextProps,
20
+ Props as ComponentProps,
21
+ RenderOptions,
22
+ TypedObject,
23
+ } from "../lib/types";
24
+
25
+ import {
26
+ isComponent,
27
+ isSlotName,
28
+ mergeComponents,
29
+ setNodeComponents,
30
+ getNodeComponents,
31
+ toSlotName,
32
+ } from "../lib/internal";
33
+ import type { SlotNodeType } from "../lib/internal";
34
+
35
+ import {
36
+ getWarningMessage,
37
+ ignoredChildrenWarning,
38
+ printWarning,
39
+ unknownSlotWarning,
40
+ } from "../lib/warnings";
41
+ import { key as contextRef } from "../lib/context";
42
+
43
+ import Block from "./Block.astro";
44
+ import HardBreak from "./HardBreak.astro";
45
+ import List from "./List.astro";
46
+ import ListItem from "./ListItem.astro";
47
+ import Mark from "./Mark.astro";
48
+ import Text from "./Text.astro";
49
+ import UnknownBlock from "./UnknownBlock.astro";
50
+ import UnknownList from "./UnknownList.astro";
51
+ import UnknownListItem from "./UnknownListItem.astro";
52
+ import UnknownMark from "./UnknownMark.astro";
53
+ import UnknownType from "./UnknownType.astro";
54
+
55
+ export type Props = PortableTextProps;
56
+
57
+ const {
58
+ value,
59
+ components: componentOverrides = {},
60
+ listNestingMode = LIST_NEST_MODE_HTML,
61
+ onMissingComponent = true,
62
+ } = Astro.props;
63
+
64
+ const components = mergeComponents(
65
+ {
66
+ type: {},
67
+ unknownType: UnknownType,
68
+ block: {
69
+ h1: Block,
70
+ h2: Block,
71
+ h3: Block,
72
+ h4: Block,
73
+ h5: Block,
74
+ h6: Block,
75
+ blockquote: Block,
76
+ normal: Block,
77
+ },
78
+ unknownBlock: UnknownBlock,
79
+ list: {
80
+ bullet: List,
81
+ number: List,
82
+ menu: List,
83
+ },
84
+ unknownList: UnknownList,
85
+ listItem: {
86
+ bullet: ListItem,
87
+ number: ListItem,
88
+ menu: ListItem,
89
+ },
90
+ unknownListItem: UnknownListItem,
91
+ mark: {
92
+ code: Mark,
93
+ em: Mark,
94
+ link: Mark,
95
+ "strike-through": Mark,
96
+ strong: Mark,
97
+ underline: Mark,
98
+ },
99
+ unknownMark: UnknownMark,
100
+ text: Text,
101
+ hardBreak: HardBreak,
102
+ },
103
+ componentOverrides
104
+ ) as PortableTextComponents;
105
+
106
+ const noop = () => {};
107
+
108
+ const missingComponentHandler = ((
109
+ handler: unknown
110
+ ): MissingComponentHandler => {
111
+ if (typeof handler === "function") {
112
+ return handler as MissingComponentHandler;
113
+ }
114
+ return !handler ? noop : printWarning;
115
+ })(onMissingComponent);
116
+
117
+ const asComponentProps = <T extends TypedObject>(
118
+ node: T,
119
+ index: number,
120
+ isInline: boolean
121
+ ): ComponentProps<T> => ({
122
+ node,
123
+ index,
124
+ isInline,
125
+ });
126
+
127
+ const provideComponent = (
128
+ nodeType: NodeType,
129
+ type: string,
130
+ fallbackComponent: Component
131
+ ): Component => {
132
+ const component: Component | undefined = ((entry) => {
133
+ return entry[type as keyof typeof entry] || entry;
134
+ })(components[nodeType]);
135
+
136
+ if (isComponent(component)) {
137
+ return component;
138
+ }
139
+
140
+ missingComponentHandler(getWarningMessage(nodeType, type), {
141
+ nodeType,
142
+ type,
143
+ });
144
+
145
+ return fallbackComponent;
146
+ };
147
+
148
+ type RunArgs = {
149
+ nodeType: SlotNodeType;
150
+ /** Block style, list type, mark type or custom `_type`, when there is one */
151
+ type?: string;
152
+ // Loosely typed: each `RenderOptions` entry narrows `node` to its own type
153
+ handler: ((handlerProps: any) => any) | undefined;
154
+ /** Called only if something actually asks for the node's `Component` */
155
+ resolveComponent: () => Component;
156
+ props: ComponentProps<TypedObject>;
157
+ children?: unknown;
158
+ };
159
+
160
+ // The local render function will override these options
161
+ let fallbackRenderOptions: Required<RenderOptions> | undefined;
162
+
163
+ const portableTextRender = (options: RenderOptions, isInline?: boolean) => {
164
+ if (!fallbackRenderOptions) {
165
+ throw new Error(
166
+ "[PortableText portableTextRender] fallbackRenderOptions is undefined"
167
+ );
168
+ }
169
+
170
+ const renderChildren = (children?: TypedObject[], inline = false) => {
171
+ return children?.map(portableTextRender(options, inline)) ?? [];
172
+ };
173
+
174
+ const renderOptions = { ...fallbackRenderOptions, ...options };
175
+
176
+ return function renderNode(node: TypedObject, index: number): any {
177
+ function run({
178
+ nodeType,
179
+ type,
180
+ handler,
181
+ resolveComponent,
182
+ props,
183
+ children,
184
+ }: RunArgs) {
185
+ let component: Component | undefined;
186
+
187
+ const handlerProps = {
188
+ // Resolved lazily. A slot rendering its own markup never asks for
189
+ // `Component`, and so must not trigger a missing component warning.
190
+ get Component() {
191
+ return (component ??= resolveComponent());
192
+ },
193
+ props,
194
+ children,
195
+ };
196
+
197
+ // A slot always wins. `renderOptions` may have been overridden by a component
198
+ // from the `components` prop calling `render` from `usePortableText`, which
199
+ // must not take the slot away from the author of this `PortableText`.
200
+ const slotRenderer = provideSlot(nodeType, type);
201
+
202
+ if (slotRenderer) {
203
+ return slotRenderer([handlerProps]);
204
+ }
205
+
206
+ if (!isComponent(handler)) {
207
+ throw new Error(
208
+ `[PortableText render] No handler found for node type ${node._type}.`
209
+ );
210
+ }
211
+
212
+ return handler(handlerProps);
213
+ }
214
+
215
+ if (isPortableTextToolkitList(node)) {
216
+ const listType = node.listItem;
217
+ const UnknownComponent = components.unknownList ?? UnknownList;
218
+ setNodeComponents(node, List, UnknownComponent);
219
+
220
+ return run({
221
+ nodeType: "list",
222
+ type: listType,
223
+ handler: renderOptions.list,
224
+ resolveComponent: () =>
225
+ provideComponent("list", listType, UnknownComponent),
226
+ props: asComponentProps(node, index, false),
227
+ children: renderChildren(node.children, false),
228
+ });
229
+ }
230
+
231
+ if (isPortableTextListItemBlock(node)) {
232
+ const { listItem, ...blockNode } = node;
233
+ const isStyled = node.style && node.style !== "normal";
234
+ // Apply block style if defined (and not "normal"), otherwise render with marks.
235
+ node.children = isStyled
236
+ ? renderNode(blockNode, index)
237
+ : buildMarksTree(node);
238
+
239
+ const UnknownComponent = components.unknownListItem ?? UnknownListItem;
240
+ setNodeComponents(node, ListItem, UnknownComponent);
241
+
242
+ return run({
243
+ nodeType: "listItem",
244
+ type: listItem,
245
+ handler: renderOptions.listItem,
246
+ resolveComponent: () =>
247
+ provideComponent("listItem", listItem, UnknownComponent),
248
+ props: asComponentProps(node, index, false),
249
+ children: isStyled
250
+ ? node.children
251
+ : renderChildren(node.children, true),
252
+ });
253
+ }
254
+
255
+ if (isPortableTextToolkitSpan(node)) {
256
+ const markType = node.markType;
257
+ const UnknownComponent = components.unknownMark ?? UnknownMark;
258
+ setNodeComponents(node, Mark, UnknownComponent);
259
+
260
+ return run({
261
+ nodeType: "mark",
262
+ type: markType,
263
+ handler: renderOptions.mark,
264
+ resolveComponent: () =>
265
+ provideComponent("mark", markType, UnknownComponent),
266
+ props: asComponentProps(node, index, true),
267
+ children: renderChildren(node.children, true),
268
+ });
269
+ }
270
+
271
+ if (isPortableTextBlock(node)) {
272
+ node.style ??= "normal"; /* Make sure style has been set */
273
+ node.children = buildMarksTree(node);
274
+
275
+ const style = node.style;
276
+ const UnknownComponent = components.unknownBlock ?? UnknownBlock;
277
+ setNodeComponents(node, Block, UnknownComponent);
278
+
279
+ return run({
280
+ nodeType: "block",
281
+ type: style,
282
+ handler: renderOptions.block,
283
+ resolveComponent: () =>
284
+ provideComponent("block", style, UnknownComponent),
285
+ props: asComponentProps(node, index, false),
286
+ children: renderChildren(node.children, true),
287
+ });
288
+ }
289
+
290
+ if (isPortableTextToolkitTextNode(node)) {
291
+ const isHardBreak = "\n" === node.text;
292
+ const props = asComponentProps(node, index, true);
293
+
294
+ if (isHardBreak) {
295
+ return run({
296
+ nodeType: "hardBreak",
297
+ handler: renderOptions.hardBreak,
298
+ resolveComponent: () =>
299
+ isComponent(components.hardBreak)
300
+ ? components.hardBreak
301
+ : HardBreak,
302
+ props,
303
+ });
304
+ }
305
+
306
+ return run({
307
+ nodeType: "text",
308
+ handler: renderOptions.text,
309
+ resolveComponent: () =>
310
+ isComponent(components.text) ? components.text : Text,
311
+ props,
312
+ });
313
+ }
314
+
315
+ // Custom type
316
+ const customType = node._type;
317
+ const UnknownComponent = components.unknownType ?? UnknownType;
318
+
319
+ return run({
320
+ nodeType: "type",
321
+ type: customType,
322
+ handler: renderOptions.type,
323
+ resolveComponent: () =>
324
+ provideComponent("type", customType, UnknownComponent),
325
+ props: asComponentProps(
326
+ node,
327
+ index,
328
+ isInline ?? false /* default to block */
329
+ ),
330
+ });
331
+ };
332
+ };
333
+
334
+ (globalThis as any)[contextRef] = (
335
+ node: TypedObject & { children?: TypedObject[] }
336
+ ): Context => ({
337
+ getDefaultComponent: provideDefaultComponent.bind(null, node),
338
+ getUnknownComponent: provideUnknownComponent.bind(null, node),
339
+ render: (options) => node.children?.map(portableTextRender(options)),
340
+ });
341
+
342
+ // Returns the `default` component related to the passed in node
343
+ const provideDefaultComponent = (node: TypedObject) => {
344
+ const DefaultComponent = getNodeComponents(node)?.Default;
345
+ if (DefaultComponent) return DefaultComponent;
346
+
347
+ // Cache missed use manual lookup
348
+ if (import.meta.env.DEV) {
349
+ // oxlint-disable-next-line no-console
350
+ console.warn(
351
+ `[@portabletext/astro] Cache missed for default component on node type "${node._type}". Ensure you are passing in the original "node" prop.`
352
+ );
353
+ }
354
+
355
+ if (isPortableTextToolkitList(node)) return List;
356
+ if (isPortableTextListItemBlock(node)) return ListItem;
357
+ if (isPortableTextToolkitSpan(node)) return Mark;
358
+ if (isPortableTextBlock(node)) return Block;
359
+
360
+ if (isPortableTextToolkitTextNode(node)) {
361
+ return "\n" === node.text ? HardBreak : Text;
362
+ }
363
+
364
+ return UnknownType;
365
+ };
366
+
367
+ // Returns the `unknown` component related to the passed in node
368
+ const provideUnknownComponent = (node: TypedObject) => {
369
+ const UnknownComponent = getNodeComponents(node)?.Unknown;
370
+ if (UnknownComponent) return UnknownComponent;
371
+
372
+ // Cache missed use manual lookup
373
+ if (import.meta.env.DEV) {
374
+ // oxlint-disable-next-line no-console
375
+ console.warn(
376
+ `[@portabletext/astro] Cache missed for unknown component on node type "${node._type}". Ensure you are passing in the original "node" prop.`
377
+ );
378
+ }
379
+
380
+ if (isPortableTextToolkitList(node)) {
381
+ return components.unknownList ?? UnknownList;
382
+ }
383
+
384
+ if (isPortableTextListItemBlock(node)) {
385
+ return components.unknownListItem ?? UnknownListItem;
386
+ }
387
+
388
+ if (isPortableTextToolkitSpan(node)) {
389
+ return components.unknownMark ?? UnknownMark;
390
+ }
391
+
392
+ if (isPortableTextBlock(node)) {
393
+ return components.unknownBlock ?? UnknownBlock;
394
+ }
395
+
396
+ if (!isPortableTextToolkitTextNode(node)) {
397
+ return components.unknownType ?? UnknownType;
398
+ }
399
+
400
+ throw new Error(
401
+ `[PortableText getUnknownComponent] Unable to provide component with node type ${node._type}`
402
+ );
403
+ };
404
+
405
+ // Make sure we have an array of blocks
406
+ const blocks = Array.isArray(value) ? value : value ? [value] : [];
407
+ const nodes = nestLists(blocks, listNestingMode);
408
+
409
+ const render = (options: NonNullable<typeof fallbackRenderOptions>) => {
410
+ fallbackRenderOptions = options;
411
+ return portableTextRender(options);
412
+ };
413
+
414
+ // Create a slot renderer for a specific slot
415
+ const createSlotRenderer = (slotName: string) =>
416
+ Astro.slots.render.bind(Astro.slots, slotName);
417
+
418
+ type SlotRenderer = ReturnType<typeof createSlotRenderer>;
419
+
420
+ const slotRenderers = new Map<string, SlotRenderer | undefined>();
421
+
422
+ const provideSlotRenderer = (slotName: string): SlotRenderer | undefined => {
423
+ if (!slotRenderers.has(slotName)) {
424
+ slotRenderers.set(
425
+ slotName,
426
+ Astro.slots.has(slotName) ? createSlotRenderer(slotName) : undefined
427
+ );
428
+ }
429
+
430
+ return slotRenderers.get(slotName);
431
+ };
432
+
433
+ /**
434
+ * Resolves the slot to render a node with.
435
+ * A slot scoped to a type, e.g. `block:h1`, takes precedence over `block`.
436
+ */
437
+ const provideSlot = (
438
+ nodeType: SlotNodeType,
439
+ type?: string
440
+ ): SlotRenderer | undefined =>
441
+ (type ? provideSlotRenderer(toSlotName(nodeType, type)) : undefined) ??
442
+ provideSlotRenderer(nodeType);
443
+
444
+ // A slot targeting an unrecognised node type never renders, so let the author
445
+ // know rather than silently ignoring it.
446
+ if (import.meta.env.DEV) {
447
+ for (const slotName of Object.keys(Astro.slots)) {
448
+ if (isSlotName(slotName)) continue;
449
+
450
+ printWarning(
451
+ "default" === slotName
452
+ ? ignoredChildrenWarning()
453
+ : unknownSlotWarning(slotName)
454
+ );
455
+ }
456
+ }
457
+
458
+ // Slots are resolved by `portableTextRender`, so every node type falls back to
459
+ // simply rendering its resolved component.
460
+ type RenderNode = (
461
+ props: Parameters<NonNullable<RenderOptions[keyof RenderOptions]>>[0]
462
+ ) => any;
463
+ ---
464
+
465
+ {
466
+ (() => {
467
+ const renderNode: RenderNode = ({ Component, props, children }) => (
468
+ <Component {...(props as any)}>{children}</Component>
469
+ );
470
+
471
+ return nodes.map(
472
+ render({
473
+ type: renderNode,
474
+ block: renderNode,
475
+ list: renderNode,
476
+ listItem: renderNode,
477
+ mark: renderNode,
478
+ text: renderNode,
479
+ hardBreak: renderNode,
480
+ })
481
+ );
482
+ })()
483
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ import type { TextNode, Props as $ } from "../lib/types";
3
+
4
+ export type Props = $<TextNode>;
5
+
6
+ const { node } = Astro.props;
7
+ ---
8
+
9
+ {node.text}
@@ -0,0 +1,7 @@
1
+ ---
2
+ import type { Block, Props as $ } from "../lib/types";
3
+
4
+ export type Props = $<Block>;
5
+ ---
6
+
7
+ <p data-portabletext-unknown="block"><slot /></p>
@@ -0,0 +1,7 @@
1
+ ---
2
+ import type { Props as $, List } from "../lib/types";
3
+
4
+ export type Props = $<List>;
5
+ ---
6
+
7
+ <ul data-portabletext-unknown="list"><slot /></ul>
@@ -0,0 +1,7 @@
1
+ ---
2
+ import type { Props as $, ListItem } from "../lib/types";
3
+
4
+ export type Props = $<ListItem>;
5
+ ---
6
+
7
+ <li data-portabletext-unknown="listitem"><slot /></li>
@@ -0,0 +1,7 @@
1
+ ---
2
+ import type { Props as $, Mark } from "../lib/types";
3
+
4
+ export type Props = $<Mark>;
5
+ ---
6
+
7
+ <span data-portabletext-unknown="mark"><slot /></span>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import type { TypedObject, Props as $ } from "../lib/types";
3
+ import { getWarningMessage } from "../lib/warnings";
4
+
5
+ export type Props = $<TypedObject>;
6
+
7
+ const { node, isInline } = Astro.props;
8
+ const warning = getWarningMessage("type", node._type);
9
+ ---
10
+
11
+ {
12
+ isInline ? (
13
+ <span style="display:none" data-portabletext-unknown="type">
14
+ {warning}
15
+ </span>
16
+ ) : (
17
+ <div style="display:none" data-portabletext-unknown="type">
18
+ {warning}
19
+ </div>
20
+ )
21
+ }
package/lib/astro.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /// <reference types="astro/client" />
2
+
3
+ declare module '*.astro' {
4
+ type Props = any
5
+ const Component: (props: Props) => any
6
+
7
+ export default Component
8
+ export type {Props}
9
+ }
@@ -0,0 +1,4 @@
1
+ export {default as Block} from '../components/Block.astro'
2
+ export {default as List} from '../components/List.astro'
3
+ export {default as ListItem} from '../components/ListItem.astro'
4
+ export {default as Mark} from '../components/Mark.astro'
package/lib/context.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type {TypedObject} from '@portabletext/types'
2
+
3
+ import type {Context} from './types'
4
+
5
+ export const key = Symbol('@portabletext/astro')
6
+
7
+ /**
8
+ * This function returns rendering utility functions within a Portable Text tree. It should
9
+ * only be used within an Astro component that has been passed into the PortableText `components` prop.
10
+ * It follows a naming convention similar to React hooks, though it is not a hook as such.
11
+ *
12
+ * @param node - The Portable Text node that was passed into the Astro component
13
+ * @returns Rendering utility functions
14
+ */
15
+ export function usePortableText(node: TypedObject) {
16
+ if (!(key in globalThis)) {
17
+ throw new Error(`PortableText "context" has not been initialised`)
18
+ }
19
+
20
+ return (globalThis as any)[key](node) as Context
21
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {default as PortableText} from '../components/PortableText.astro'
2
+ export * from './utils'