@portabletext/astro 0.0.0 → 0.1.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,393 @@
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
+ mergeComponents,
28
+ setNodeComponents,
29
+ getNodeComponents,
30
+ } from "../lib/internal";
31
+
32
+ import { getWarningMessage, printWarning } from "../lib/warnings";
33
+ import { key as contextRef } from "../lib/context";
34
+
35
+ import Block from "./Block.astro";
36
+ import HardBreak from "./HardBreak.astro";
37
+ import List from "./List.astro";
38
+ import ListItem from "./ListItem.astro";
39
+ import Mark from "./Mark.astro";
40
+ import Text from "./Text.astro";
41
+ import UnknownBlock from "./UnknownBlock.astro";
42
+ import UnknownList from "./UnknownList.astro";
43
+ import UnknownListItem from "./UnknownListItem.astro";
44
+ import UnknownMark from "./UnknownMark.astro";
45
+ import UnknownType from "./UnknownType.astro";
46
+
47
+ export type Props = PortableTextProps;
48
+
49
+ const {
50
+ value,
51
+ components: componentOverrides = {},
52
+ listNestingMode = LIST_NEST_MODE_HTML,
53
+ onMissingComponent = true,
54
+ } = Astro.props;
55
+
56
+ const components = mergeComponents(
57
+ {
58
+ type: {},
59
+ unknownType: UnknownType,
60
+ block: {
61
+ h1: Block,
62
+ h2: Block,
63
+ h3: Block,
64
+ h4: Block,
65
+ h5: Block,
66
+ h6: Block,
67
+ blockquote: Block,
68
+ normal: Block,
69
+ },
70
+ unknownBlock: UnknownBlock,
71
+ list: {
72
+ bullet: List,
73
+ number: List,
74
+ menu: List,
75
+ },
76
+ unknownList: UnknownList,
77
+ listItem: {
78
+ bullet: ListItem,
79
+ number: ListItem,
80
+ menu: ListItem,
81
+ },
82
+ unknownListItem: UnknownListItem,
83
+ mark: {
84
+ code: Mark,
85
+ em: Mark,
86
+ link: Mark,
87
+ "strike-through": Mark,
88
+ strong: Mark,
89
+ underline: Mark,
90
+ },
91
+ unknownMark: UnknownMark,
92
+ text: Text,
93
+ hardBreak: HardBreak,
94
+ },
95
+ componentOverrides
96
+ ) as PortableTextComponents;
97
+
98
+ const noop = () => {};
99
+
100
+ const missingComponentHandler = ((
101
+ handler: unknown
102
+ ): MissingComponentHandler => {
103
+ if (typeof handler === "function") {
104
+ return handler as MissingComponentHandler;
105
+ }
106
+ return !handler ? noop : printWarning;
107
+ })(onMissingComponent);
108
+
109
+ const asComponentProps = <T extends TypedObject>(
110
+ node: T,
111
+ index: number,
112
+ isInline: boolean
113
+ ): ComponentProps<T> => ({
114
+ node,
115
+ index,
116
+ isInline,
117
+ });
118
+
119
+ const provideComponent = (
120
+ nodeType: NodeType,
121
+ type: string,
122
+ fallbackComponent: Component
123
+ ): Component => {
124
+ const component: Component | undefined = ((entry) => {
125
+ return entry[type as keyof typeof entry] || entry;
126
+ })(components[nodeType]);
127
+
128
+ if (isComponent(component)) {
129
+ return component;
130
+ }
131
+
132
+ missingComponentHandler(getWarningMessage(nodeType, type), {
133
+ nodeType,
134
+ type,
135
+ });
136
+
137
+ return fallbackComponent;
138
+ };
139
+
140
+ // The local render function will override these options
141
+ let fallbackRenderOptions: Required<RenderOptions> | undefined;
142
+
143
+ const portableTextRender = (options: RenderOptions, isInline?: boolean) => {
144
+ if (!fallbackRenderOptions) {
145
+ throw new Error(
146
+ "[PortableText portableTextRender] fallbackRenderOptions is undefined"
147
+ );
148
+ }
149
+
150
+ const renderChildren = (children?: TypedObject[], inline = false) => {
151
+ return children?.map(portableTextRender(options, inline)) ?? [];
152
+ };
153
+
154
+ const renderOptions = { ...fallbackRenderOptions, ...options };
155
+
156
+ return function renderNode(node: TypedObject, index: number): any {
157
+ function run<H extends (...args: any) => any, P = Parameters<H>[0]>(
158
+ handler: H | undefined,
159
+ props: P
160
+ ) {
161
+ if (!isComponent(handler)) {
162
+ throw new Error(
163
+ `[PortableText render] No handler found for node type ${node._type}.`
164
+ );
165
+ }
166
+
167
+ return handler(props);
168
+ }
169
+
170
+ if (isPortableTextToolkitList(node)) {
171
+ const UnknownComponent = components.unknownList ?? UnknownList;
172
+ setNodeComponents(node, List, UnknownComponent);
173
+
174
+ return run(renderOptions.list, {
175
+ Component: provideComponent("list", node.listItem, UnknownComponent),
176
+ props: asComponentProps(node, index, false),
177
+ children: renderChildren(node.children, false),
178
+ });
179
+ }
180
+
181
+ if (isPortableTextListItemBlock(node)) {
182
+ const { listItem, ...blockNode } = node;
183
+ const isStyled = node.style && node.style !== "normal";
184
+ // Apply block style if defined (and not "normal"), otherwise render with marks.
185
+ node.children = isStyled
186
+ ? renderNode(blockNode, index)
187
+ : buildMarksTree(node);
188
+
189
+ const UnknownComponent = components.unknownListItem ?? UnknownListItem;
190
+ setNodeComponents(node, ListItem, UnknownComponent);
191
+
192
+ return run(renderOptions.listItem, {
193
+ Component: provideComponent(
194
+ "listItem",
195
+ node.listItem,
196
+ UnknownComponent
197
+ ),
198
+ props: asComponentProps(node, index, false),
199
+ children: isStyled
200
+ ? node.children
201
+ : renderChildren(node.children, true),
202
+ });
203
+ }
204
+
205
+ if (isPortableTextToolkitSpan(node)) {
206
+ const UnknownComponent = components.unknownMark ?? UnknownMark;
207
+ setNodeComponents(node, Mark, UnknownComponent);
208
+
209
+ return run(renderOptions.mark, {
210
+ Component: provideComponent("mark", node.markType, UnknownComponent),
211
+ props: asComponentProps(node, index, true),
212
+ children: renderChildren(node.children, true),
213
+ });
214
+ }
215
+
216
+ if (isPortableTextBlock(node)) {
217
+ node.style ??= "normal"; /* Make sure style has been set */
218
+ node.children = buildMarksTree(node);
219
+
220
+ const UnknownComponent = components.unknownBlock ?? UnknownBlock;
221
+ setNodeComponents(node, Block, UnknownComponent);
222
+
223
+ return run(renderOptions.block, {
224
+ Component: provideComponent("block", node.style, UnknownComponent),
225
+ props: asComponentProps(node, index, false),
226
+ children: renderChildren(node.children, true),
227
+ });
228
+ }
229
+
230
+ if (isPortableTextToolkitTextNode(node)) {
231
+ const isHardBreak = "\n" === node.text;
232
+ const props = asComponentProps(node, index, true);
233
+
234
+ if (isHardBreak) {
235
+ return run(renderOptions.hardBreak, {
236
+ Component: isComponent(components.hardBreak)
237
+ ? components.hardBreak
238
+ : HardBreak,
239
+ props,
240
+ });
241
+ }
242
+
243
+ return run(renderOptions.text, {
244
+ Component: isComponent(components.text) ? components.text : Text,
245
+ props,
246
+ });
247
+ }
248
+
249
+ // Custom type
250
+ const UnknownComponent = components.unknownType ?? UnknownType;
251
+
252
+ return run(renderOptions.type, {
253
+ Component: provideComponent("type", node._type, UnknownComponent),
254
+ props: asComponentProps(
255
+ node,
256
+ index,
257
+ isInline ?? false /* default to block */
258
+ ),
259
+ });
260
+ };
261
+ };
262
+
263
+ (globalThis as any)[contextRef] = (
264
+ node: TypedObject & { children?: TypedObject[] }
265
+ ): Context => ({
266
+ getDefaultComponent: provideDefaultComponent.bind(null, node),
267
+ getUnknownComponent: provideUnknownComponent.bind(null, node),
268
+ render: (options) => node.children?.map(portableTextRender(options)),
269
+ });
270
+
271
+ // Returns the `default` component related to the passed in node
272
+ const provideDefaultComponent = (node: TypedObject) => {
273
+ const DefaultComponent = getNodeComponents(node)?.Default;
274
+ if (DefaultComponent) return DefaultComponent;
275
+
276
+ // Cache missed use manual lookup
277
+ if (import.meta.env.DEV) {
278
+ // oxlint-disable-next-line no-console
279
+ console.warn(
280
+ `[@portabletext/astro] Cache missed for default component on node type "${node._type}". Ensure you are passing in the original "node" prop.`
281
+ );
282
+ }
283
+
284
+ if (isPortableTextToolkitList(node)) return List;
285
+ if (isPortableTextListItemBlock(node)) return ListItem;
286
+ if (isPortableTextToolkitSpan(node)) return Mark;
287
+ if (isPortableTextBlock(node)) return Block;
288
+
289
+ if (isPortableTextToolkitTextNode(node)) {
290
+ return "\n" === node.text ? HardBreak : Text;
291
+ }
292
+
293
+ return UnknownType;
294
+ };
295
+
296
+ // Returns the `unknown` component related to the passed in node
297
+ const provideUnknownComponent = (node: TypedObject) => {
298
+ const UnknownComponent = getNodeComponents(node)?.Unknown;
299
+ if (UnknownComponent) return UnknownComponent;
300
+
301
+ // Cache missed use manual lookup
302
+ if (import.meta.env.DEV) {
303
+ // oxlint-disable-next-line no-console
304
+ console.warn(
305
+ `[@portabletext/astro] Cache missed for unknown component on node type "${node._type}". Ensure you are passing in the original "node" prop.`
306
+ );
307
+ }
308
+
309
+ if (isPortableTextToolkitList(node)) {
310
+ return components.unknownList ?? UnknownList;
311
+ }
312
+
313
+ if (isPortableTextListItemBlock(node)) {
314
+ return components.unknownListItem ?? UnknownListItem;
315
+ }
316
+
317
+ if (isPortableTextToolkitSpan(node)) {
318
+ return components.unknownMark ?? UnknownMark;
319
+ }
320
+
321
+ if (isPortableTextBlock(node)) {
322
+ return components.unknownBlock ?? UnknownBlock;
323
+ }
324
+
325
+ if (!isPortableTextToolkitTextNode(node)) {
326
+ return components.unknownType ?? UnknownType;
327
+ }
328
+
329
+ throw new Error(
330
+ `[PortableText getUnknownComponent] Unable to provide component with node type ${node._type}`
331
+ );
332
+ };
333
+
334
+ // Make sure we have an array of blocks
335
+ const blocks = Array.isArray(value) ? value : value ? [value] : [];
336
+ const nodes = nestLists(blocks, listNestingMode);
337
+
338
+ const render = (options: NonNullable<typeof fallbackRenderOptions>) => {
339
+ fallbackRenderOptions = options;
340
+ return portableTextRender(options);
341
+ };
342
+
343
+ // Create a slot renderer for a specific slot
344
+ const createSlotRenderer = (slotName: string) =>
345
+ Astro.slots.render.bind(Astro.slots, slotName);
346
+
347
+ type SlotRenderer = ReturnType<typeof createSlotRenderer>;
348
+
349
+ const slots = [
350
+ "type",
351
+ "block",
352
+ "list",
353
+ "listItem",
354
+ "mark",
355
+ "text",
356
+ "hardBreak",
357
+ ].reduce(
358
+ (obj, name) => {
359
+ obj[name] = Astro.slots.has(name) ? createSlotRenderer(name) : undefined;
360
+ return obj;
361
+ },
362
+ {} as Record<string, SlotRenderer | undefined>
363
+ );
364
+
365
+ type RenderNode = (
366
+ slotRenderer: SlotRenderer | undefined
367
+ ) => (
368
+ props: Parameters<NonNullable<RenderOptions[keyof RenderOptions]>>[0]
369
+ ) => any;
370
+ ---
371
+
372
+ {
373
+ (() => {
374
+ const renderNode: RenderNode = (slotRenderer) => {
375
+ return ({ Component, props, children }) =>
376
+ slotRenderer?.([{ Component, props, children }]) ?? (
377
+ <Component {...(props as any)}>{children}</Component>
378
+ );
379
+ };
380
+
381
+ return nodes.map(
382
+ render({
383
+ type: renderNode(slots.type),
384
+ block: renderNode(slots.block),
385
+ list: renderNode(slots.list),
386
+ listItem: renderNode(slots.listItem),
387
+ mark: renderNode(slots.mark),
388
+ text: renderNode(slots.text),
389
+ hardBreak: renderNode(slots.hardBreak),
390
+ })
391
+ );
392
+ })()
393
+ }
@@ -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'
@@ -0,0 +1,103 @@
1
+ import type {Component, ComponentOrRecord, SomePortableTextComponents, TypedObject} from './types'
2
+
3
+ /**
4
+ * Helper for component to throw an error
5
+ * @param err
6
+ */
7
+ export function throwError(err: Error | string): never {
8
+ throw err
9
+ }
10
+
11
+ /**
12
+ * Returns true if `it` is component
13
+ */
14
+ export function isComponent(it: unknown): it is Component {
15
+ return typeof it === 'function'
16
+ }
17
+
18
+ /**
19
+ * Merges two {@link SomePortableTextComponents} objects, giving priority to overrides.
20
+ *
21
+ * This function combines two component objects used in Portable Text rendering.
22
+ * If both objects have the same key, the value from `overrides` takes precedence.
23
+ * This is useful for customizing the rendering of specific components while keeping
24
+ * the default behavior for others.
25
+ *
26
+ * @typeParam Components - The type of the base components object.
27
+ * @typeParam Overrides - The type of the overrides components object.
28
+ *
29
+ * @param components - The base components object.
30
+ * @param overrides - The overrides components object.
31
+ *
32
+ * @returns A new object with the merged components.
33
+ */
34
+ export function mergeComponents<
35
+ Components extends SomePortableTextComponents,
36
+ Overrides extends SomePortableTextComponents,
37
+ >(components: Components, overrides: Overrides) {
38
+ const cmps = {...components} as Record<string, ComponentOrRecord>
39
+
40
+ for (const [key, override] of Object.entries(overrides)) {
41
+ const current = components[key as keyof typeof components]
42
+
43
+ const value =
44
+ !current || isComponent(override) || isComponent(current)
45
+ ? override
46
+ : {
47
+ ...(current as Record<string, Component>),
48
+ ...(override as Record<string, Component>),
49
+ }
50
+
51
+ cmps[key] = value
52
+ }
53
+
54
+ return cmps as {
55
+ [Key in keyof (Components & Overrides)]: Key extends keyof (Overrides | Components)
56
+ ? Overrides[Key] extends Component
57
+ ? Overrides[Key]
58
+ : Components[Key] extends Component
59
+ ? Overrides[Key]
60
+ : (Overrides & Components)[Key]
61
+ : (Overrides & Components)[Key]
62
+ }
63
+ }
64
+
65
+ /**
66
+ * ========================
67
+ * Node Components Registry
68
+ * ========================
69
+ */
70
+
71
+ type ResolvedComponents = {
72
+ Default: Component
73
+ Unknown: Component
74
+ }
75
+
76
+ const nodeComponentsMap = new WeakMap<TypedObject, ResolvedComponents>()
77
+
78
+ /**
79
+ * Binds the resolved components to a specific node object.
80
+ * @internal
81
+ *
82
+ * @remarks
83
+ * This uses the node's _object reference_ as the key. This enables the `Context` API via
84
+ * `usePortableText` to look up which components were assigned to this specific node during rendering.
85
+ *
86
+ * @param node - The node object to be used as the key.
87
+ * @param Default - The resolved default component for this node.
88
+ * @param Unknown - The resolved fallback (unknown) component for this node.
89
+ */
90
+ export function setNodeComponents(node: TypedObject, Default: Component, Unknown: Component): void {
91
+ nodeComponentsMap.set(node, {Default, Unknown})
92
+ }
93
+
94
+ /**
95
+ * Retrieves the components bound to a specific node object.
96
+ * @internal
97
+ *
98
+ * @param node - The node object to look up (by reference).
99
+ * @returns The component pair, or `undefined` if this exact node object was not registered.
100
+ */
101
+ export function getNodeComponents(node: TypedObject): ResolvedComponents | undefined {
102
+ return nodeComponentsMap.get(node)
103
+ }