@jsondrop/drupal 0.0.0-stage → 0.1.1
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.
- package/LICENSE.md +21 -0
- package/README.md +78 -2
- package/dist/account.d.ts +65 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +96 -0
- package/dist/account.js.map +1 -0
- package/dist/entities.d.ts +205 -0
- package/dist/entities.d.ts.map +1 -0
- package/dist/entities.js +2 -0
- package/dist/entities.js.map +1 -0
- package/dist/fields.d.ts +73 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +7 -0
- package/dist/fields.js.map +1 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +40 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/menu.d.ts +48 -0
- package/dist/menu.d.ts.map +1 -0
- package/dist/menu.js +35 -0
- package/dist/menu.js.map +1 -0
- package/dist/router.d.ts +56 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +52 -0
- package/dist/router.js.map +1 -0
- package/dist/views.d.ts +29 -0
- package/dist/views.d.ts.map +1 -0
- package/dist/views.js +32 -0
- package/dist/views.js.map +1 -0
- package/package.json +56 -4
- package/src/account.ts +125 -0
- package/src/entities.ts +232 -0
- package/src/fields.ts +81 -0
- package/src/files.ts +52 -0
- package/src/index.ts +7 -0
- package/src/menu.ts +71 -0
- package/src/router.ts +89 -0
- package/src/views.ts +50 -0
package/src/entities.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { ResourceBase, ToMany, ToOne } from '@jsondrop/core';
|
|
2
|
+
import type { DateTimeString, FileMeta, FileUri, ImageMeta, LinkField, PathField, TextField } from './fields.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Drupal 11 core entity types, with the base fields JSON:API exposes.
|
|
6
|
+
*
|
|
7
|
+
* Bundle-specific fields are the project's: extend the generic types and
|
|
8
|
+
* register the bundle.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* interface Article extends Node<'article'> {
|
|
12
|
+
* body: TextField | null;
|
|
13
|
+
* field_image: ToOne<File<ImageMeta>>;
|
|
14
|
+
* field_tags: ToMany<Term<'tags'>>;
|
|
15
|
+
* }
|
|
16
|
+
* declare module '@jsondrop/core' {
|
|
17
|
+
* interface ResourceTypes { 'node--article': Article }
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Fields hidden by access (an anonymous request sees no `mail`) or by
|
|
22
|
+
* JSON:API Extras are absent at runtime even where typed as required.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Fields shared by revisionable, translatable content entities. */
|
|
26
|
+
interface ContentEntity<TType extends string> extends ResourceBase<TType> {
|
|
27
|
+
langcode: string;
|
|
28
|
+
default_langcode?: boolean;
|
|
29
|
+
revision_translation_affected?: boolean | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Config entities: only the identifying fields are typed.
|
|
33
|
+
|
|
34
|
+
export interface NodeType extends ResourceBase<'node_type--node_type'> {
|
|
35
|
+
drupal_internal__type: string;
|
|
36
|
+
name: string;
|
|
37
|
+
description: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Vocabulary extends ResourceBase<'taxonomy_vocabulary--taxonomy_vocabulary'> {
|
|
41
|
+
drupal_internal__vid: string;
|
|
42
|
+
name: string;
|
|
43
|
+
description: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MediaType extends ResourceBase<'media_type--media_type'> {
|
|
47
|
+
drupal_internal__id: string;
|
|
48
|
+
label: string;
|
|
49
|
+
description: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Role extends ResourceBase<'user_role--user_role'> {
|
|
53
|
+
drupal_internal__id: string;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BlockContentType extends ResourceBase<'block_content_type--block_content_type'> {
|
|
58
|
+
drupal_internal__id: string;
|
|
59
|
+
label: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Content entities.
|
|
63
|
+
|
|
64
|
+
export interface User extends ContentEntity<'user--user'> {
|
|
65
|
+
drupal_internal__uid: number;
|
|
66
|
+
display_name: string;
|
|
67
|
+
name?: string;
|
|
68
|
+
mail?: string | null;
|
|
69
|
+
timezone?: string;
|
|
70
|
+
status?: boolean;
|
|
71
|
+
created?: DateTimeString;
|
|
72
|
+
changed?: DateTimeString;
|
|
73
|
+
access?: DateTimeString;
|
|
74
|
+
login?: DateTimeString | null;
|
|
75
|
+
preferred_langcode?: string;
|
|
76
|
+
roles?: ToMany<Role>;
|
|
77
|
+
user_picture?: ToOne<File<ImageMeta>>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `file--file`. `TMeta` types the relationship meta it carries when reached
|
|
82
|
+
* through a field: {@link ImageMeta} for image fields.
|
|
83
|
+
*/
|
|
84
|
+
export interface File<TMeta extends object = Record<string, unknown>> extends ContentEntity<'file--file'> {
|
|
85
|
+
drupal_internal__fid: number;
|
|
86
|
+
filename: string;
|
|
87
|
+
uri: FileUri;
|
|
88
|
+
filemime: string;
|
|
89
|
+
filesize: number;
|
|
90
|
+
status: boolean;
|
|
91
|
+
created: DateTimeString;
|
|
92
|
+
changed: DateTimeString;
|
|
93
|
+
uid?: ToOne<User>;
|
|
94
|
+
relationshipMeta?: TMeta & Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface Node<TBundle extends string = string> extends ContentEntity<`node--${TBundle}`> {
|
|
98
|
+
drupal_internal__nid: number;
|
|
99
|
+
drupal_internal__vid: number;
|
|
100
|
+
title: string;
|
|
101
|
+
status: boolean;
|
|
102
|
+
promote: boolean;
|
|
103
|
+
sticky: boolean;
|
|
104
|
+
created: DateTimeString;
|
|
105
|
+
changed: DateTimeString;
|
|
106
|
+
revision_timestamp?: DateTimeString;
|
|
107
|
+
revision_log?: string | null;
|
|
108
|
+
path?: PathField;
|
|
109
|
+
node_type?: ToOne<NodeType>;
|
|
110
|
+
uid?: ToOne<User>;
|
|
111
|
+
revision_uid?: ToOne<User>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface Term<TVocabulary extends string = string> extends ContentEntity<`taxonomy_term--${TVocabulary}`> {
|
|
115
|
+
drupal_internal__tid: number;
|
|
116
|
+
drupal_internal__revision_id?: number;
|
|
117
|
+
name: string;
|
|
118
|
+
description: TextField | null;
|
|
119
|
+
weight: number;
|
|
120
|
+
status: boolean;
|
|
121
|
+
changed: DateTimeString;
|
|
122
|
+
path?: PathField;
|
|
123
|
+
vid?: ToOne<Vocabulary>;
|
|
124
|
+
/** Parents; a root term's parent is a stub with the id `virtual`. */
|
|
125
|
+
parent?: ToMany<Term<TVocabulary>>;
|
|
126
|
+
revision_user?: ToOne<User>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface Media<TBundle extends string = string> extends ContentEntity<`media--${TBundle}`> {
|
|
130
|
+
drupal_internal__mid: number;
|
|
131
|
+
drupal_internal__vid: number;
|
|
132
|
+
name: string;
|
|
133
|
+
status: boolean;
|
|
134
|
+
created: DateTimeString;
|
|
135
|
+
changed: DateTimeString;
|
|
136
|
+
path?: PathField;
|
|
137
|
+
bundle?: ToOne<MediaType>;
|
|
138
|
+
thumbnail?: ToOne<File<ImageMeta>>;
|
|
139
|
+
uid?: ToOne<User>;
|
|
140
|
+
revision_user?: ToOne<User>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The media types the standard install profile creates.
|
|
144
|
+
|
|
145
|
+
export interface MediaImage extends Media<'image'> {
|
|
146
|
+
field_media_image: ToOne<File<ImageMeta>>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface MediaDocument extends Media<'document'> {
|
|
150
|
+
field_media_document: ToOne<File<FileMeta>>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface MediaAudio extends Media<'audio'> {
|
|
154
|
+
field_media_audio_file: ToOne<File<FileMeta>>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface MediaVideo extends Media<'video'> {
|
|
158
|
+
field_media_video_file: ToOne<File<FileMeta>>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface MediaRemoteVideo extends Media<'remote_video'> {
|
|
162
|
+
/** The oEmbed URL (YouTube, Vimeo). */
|
|
163
|
+
field_media_oembed_video: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface MenuLinkContent extends ContentEntity<'menu_link_content--menu_link_content'> {
|
|
167
|
+
drupal_internal__id: number;
|
|
168
|
+
title: string;
|
|
169
|
+
description: string | null;
|
|
170
|
+
menu_name: string;
|
|
171
|
+
link: LinkField;
|
|
172
|
+
enabled: boolean;
|
|
173
|
+
expanded: boolean;
|
|
174
|
+
external: boolean;
|
|
175
|
+
weight: number;
|
|
176
|
+
/** Parent plugin ID, `menu_link_content:{uuid}`, or null at the root. */
|
|
177
|
+
parent: string | null;
|
|
178
|
+
changed: DateTimeString;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface BlockContent<TBundle extends string = string> extends ContentEntity<`block_content--${TBundle}`> {
|
|
182
|
+
drupal_internal__id: number;
|
|
183
|
+
info: string;
|
|
184
|
+
status: boolean;
|
|
185
|
+
reusable: boolean;
|
|
186
|
+
changed: DateTimeString;
|
|
187
|
+
body?: TextField | null;
|
|
188
|
+
block_content_type?: ToOne<BlockContentType>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface PathAlias extends ResourceBase<'path_alias--path_alias'> {
|
|
192
|
+
drupal_internal__id: number;
|
|
193
|
+
langcode: string;
|
|
194
|
+
/** System path, e.g. `/node/1`. */
|
|
195
|
+
path: string;
|
|
196
|
+
alias: string;
|
|
197
|
+
status: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** `paragraph--{bundle}` (contrib paragraphs). */
|
|
201
|
+
export interface Paragraph<TBundle extends string = string> extends ContentEntity<`paragraph--${TBundle}`> {
|
|
202
|
+
drupal_internal__id: number;
|
|
203
|
+
drupal_internal__revision_id: number;
|
|
204
|
+
status: boolean;
|
|
205
|
+
created: DateTimeString;
|
|
206
|
+
parent_id: string;
|
|
207
|
+
parent_type: string;
|
|
208
|
+
parent_field_name: string;
|
|
209
|
+
behavior_settings?: unknown;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
declare module '@jsondrop/core' {
|
|
213
|
+
interface ResourceTypes {
|
|
214
|
+
'user--user': User;
|
|
215
|
+
'file--file': File;
|
|
216
|
+
'menu_link_content--menu_link_content': MenuLinkContent;
|
|
217
|
+
'path_alias--path_alias': PathAlias;
|
|
218
|
+
'node_type--node_type': NodeType;
|
|
219
|
+
'taxonomy_vocabulary--taxonomy_vocabulary': Vocabulary;
|
|
220
|
+
'media_type--media_type': MediaType;
|
|
221
|
+
'user_role--user_role': Role;
|
|
222
|
+
'block_content_type--block_content_type': BlockContentType;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface EntityTypes {
|
|
226
|
+
node: Node;
|
|
227
|
+
taxonomy_term: Term;
|
|
228
|
+
media: Media;
|
|
229
|
+
block_content: BlockContent;
|
|
230
|
+
paragraph: Paragraph;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field value shapes, as Drupal 11's JSON:API normalizes core field types.
|
|
3
|
+
*
|
|
4
|
+
* Dates (`created`, `changed`, datetime fields) arrive as ISO 8601 strings.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** ISO 8601 date-time string, e.g. `2026-10-31T12:00:00+00:00`. */
|
|
8
|
+
export type DateTimeString = string;
|
|
9
|
+
|
|
10
|
+
/** `text_long`, `text_with_summary` (`summary` only on the latter). */
|
|
11
|
+
export interface TextField {
|
|
12
|
+
value: string;
|
|
13
|
+
format: string | null;
|
|
14
|
+
/** The value rendered through its text format: safe HTML to output. */
|
|
15
|
+
processed: string;
|
|
16
|
+
summary?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The `path` field (core path module). */
|
|
20
|
+
export interface PathField {
|
|
21
|
+
alias: string | null;
|
|
22
|
+
pid: number | null;
|
|
23
|
+
langcode: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The `link` field type. */
|
|
27
|
+
export interface LinkField {
|
|
28
|
+
/** `internal:/…`, `entity:node/1`, `route:<nolink>` or an external URL. */
|
|
29
|
+
uri: string;
|
|
30
|
+
title: string | null;
|
|
31
|
+
options: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The file field's `uri`: the stream URI and its (root-relative) URL. */
|
|
35
|
+
export interface FileUri {
|
|
36
|
+
value: string;
|
|
37
|
+
url: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Relationship meta of an `image` field item, found on the file resource
|
|
42
|
+
* as `relationshipMeta`.
|
|
43
|
+
*/
|
|
44
|
+
export interface ImageMeta {
|
|
45
|
+
alt: string;
|
|
46
|
+
title: string;
|
|
47
|
+
width: number | null;
|
|
48
|
+
height: number | null;
|
|
49
|
+
drupal_internal__target_id: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Relationship meta of a `file` field item. */
|
|
53
|
+
export interface FileMeta {
|
|
54
|
+
display: boolean | null;
|
|
55
|
+
description: string | null;
|
|
56
|
+
drupal_internal__target_id: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `datetime_range` / `daterange`. */
|
|
60
|
+
export interface DateRangeField {
|
|
61
|
+
value: DateTimeString;
|
|
62
|
+
end_value: DateTimeString;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `address` field (contrib address module, used by Commerce). */
|
|
66
|
+
export interface AddressField {
|
|
67
|
+
country_code: string;
|
|
68
|
+
langcode?: string | null;
|
|
69
|
+
administrative_area?: string | null;
|
|
70
|
+
locality?: string | null;
|
|
71
|
+
dependent_locality?: string | null;
|
|
72
|
+
postal_code?: string | null;
|
|
73
|
+
sorting_code?: string | null;
|
|
74
|
+
address_line1?: string | null;
|
|
75
|
+
address_line2?: string | null;
|
|
76
|
+
address_line3?: string | null;
|
|
77
|
+
organization?: string | null;
|
|
78
|
+
given_name?: string | null;
|
|
79
|
+
additional_name?: string | null;
|
|
80
|
+
family_name?: string | null;
|
|
81
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isStub, type JsonApiClient } from '@jsondrop/core';
|
|
2
|
+
import type { File } from './entities.js';
|
|
3
|
+
import type { ImageMeta } from './fields.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Absolute URL of a file. Drupal 11 serves `uri.url` root-relative
|
|
7
|
+
* (`/sites/default/files/…`); this prefixes the site's origin.
|
|
8
|
+
*/
|
|
9
|
+
export function fileUrl(client: JsonApiClient, file: Pick<File, 'uri'>): string {
|
|
10
|
+
const url = file.uri.url;
|
|
11
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//') ? url : `${client.baseUrl}${url}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* URL of an image style derivative, as the consumer_image_styles module
|
|
16
|
+
* adds them to the file's links. NULL when the style is not enabled for
|
|
17
|
+
* the requesting consumer.
|
|
18
|
+
*/
|
|
19
|
+
export function imageStyleUrl(file: Pick<File, 'links'>, style: string): string | null {
|
|
20
|
+
return file.links?.[style]?.href ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Image {
|
|
24
|
+
url: string;
|
|
25
|
+
alt: string;
|
|
26
|
+
title: string | null;
|
|
27
|
+
width: number | null;
|
|
28
|
+
height: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An image field's value, ready for an `<img>`: absolute URL plus the alt
|
|
33
|
+
* text and dimensions from the relationship. NULL for an empty field or a
|
|
34
|
+
* file that was not included.
|
|
35
|
+
*/
|
|
36
|
+
export function image(client: JsonApiClient, value: unknown): Image | null {
|
|
37
|
+
if (!value || typeof value !== 'object' || isStub(value)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const file = value as File<ImageMeta>;
|
|
41
|
+
if (!file.uri?.url) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const meta: Partial<ImageMeta> = file.relationshipMeta ?? {};
|
|
45
|
+
return {
|
|
46
|
+
url: fileUrl(client, file),
|
|
47
|
+
alt: meta.alt ?? '',
|
|
48
|
+
title: meta.title || null,
|
|
49
|
+
width: meta.width ?? null,
|
|
50
|
+
height: meta.height ?? null,
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/index.ts
ADDED
package/src/menu.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { JsonApiClient, ResourceBase, ResourceRequestOptions } from '@jsondrop/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A menu link from the jsonapi_menu_items module. Unlike core's
|
|
5
|
+
* `menu_link_content` resources, it covers every link in the menu (links
|
|
6
|
+
* defined by modules too) and comes with a resolved `url`.
|
|
7
|
+
*/
|
|
8
|
+
export interface MenuItem extends ResourceBase {
|
|
9
|
+
/** Plugin ID: `menu_link_content:{uuid}`, `standard.front_page`, … */
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string | null;
|
|
13
|
+
url: string;
|
|
14
|
+
menu_name: string;
|
|
15
|
+
/** Parent plugin ID, or empty at the root. */
|
|
16
|
+
parent: string;
|
|
17
|
+
weight: number;
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
expanded: boolean;
|
|
20
|
+
provider: string;
|
|
21
|
+
route?: { name: string; parameters: Record<string, string> };
|
|
22
|
+
options?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MenuTreeItem<T> = T & { children: MenuTreeItem<T>[] };
|
|
26
|
+
|
|
27
|
+
/** A menu's enabled links, flat (jsonapi_menu_items module). */
|
|
28
|
+
export async function getMenuItems(client: JsonApiClient, menu: string, options?: ResourceRequestOptions): Promise<MenuItem[]> {
|
|
29
|
+
const response = await client.get<MenuItem[]>(`/menu_items/${encodeURIComponent(menu)}`, options);
|
|
30
|
+
return response.data ?? [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A menu as a tree, sorted by weight then title (jsonapi_menu_items module). */
|
|
34
|
+
export async function getMenu(client: JsonApiClient, menu: string, options?: ResourceRequestOptions): Promise<MenuTreeItem<MenuItem>[]> {
|
|
35
|
+
return buildMenuTree(await getMenuItems(client, menu, options));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TreeInput {
|
|
39
|
+
id: string;
|
|
40
|
+
type?: string;
|
|
41
|
+
parent: string | null;
|
|
42
|
+
weight: number;
|
|
43
|
+
title: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Nests flat menu links by `parent`, sorted by weight then title. Takes
|
|
50
|
+
* jsonapi_menu_items links or core `menu_link_content` resources (whose
|
|
51
|
+
* `parent` names the plugin ID `menu_link_content:{uuid}`). A link whose
|
|
52
|
+
* parent is not in the list becomes a root.
|
|
53
|
+
*/
|
|
54
|
+
export function buildMenuTree<T extends TreeInput>(items: T[]): MenuTreeItem<T>[] {
|
|
55
|
+
const pluginId = (item: T) => (item.type === 'menu_link_content--menu_link_content' && UUID.test(item.id) ? `menu_link_content:${item.id}` : item.id);
|
|
56
|
+
const nodes = new Map<string, MenuTreeItem<T>>();
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
nodes.set(pluginId(item), { ...item, children: [] });
|
|
59
|
+
}
|
|
60
|
+
const roots: MenuTreeItem<T>[] = [];
|
|
61
|
+
for (const node of nodes.values()) {
|
|
62
|
+
const parent = node.parent ? nodes.get(node.parent) : undefined;
|
|
63
|
+
(parent && parent !== node ? parent.children : roots).push(node);
|
|
64
|
+
}
|
|
65
|
+
const sort = (list: MenuTreeItem<T>[]) => {
|
|
66
|
+
list.sort((a, b) => a.weight - b.weight || a.title.localeCompare(b.title));
|
|
67
|
+
list.forEach((node) => sort(node.children));
|
|
68
|
+
};
|
|
69
|
+
sort(roots);
|
|
70
|
+
return roots;
|
|
71
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isJsonApiError, type JsonApiClient, type Resource, type ResourceRequestOptions } from '@jsondrop/core';
|
|
2
|
+
|
|
3
|
+
/** What decoupled_router answers for a path. */
|
|
4
|
+
export interface TranslatedPath {
|
|
5
|
+
/** Absolute URL the path resolves to. */
|
|
6
|
+
resolved: string;
|
|
7
|
+
isHomePath: boolean;
|
|
8
|
+
entity?: {
|
|
9
|
+
canonical: string;
|
|
10
|
+
type: string;
|
|
11
|
+
bundle: string;
|
|
12
|
+
/** Drupal's numeric ID, as a string. */
|
|
13
|
+
id: string;
|
|
14
|
+
uuid: string;
|
|
15
|
+
};
|
|
16
|
+
label?: string;
|
|
17
|
+
/** Present when the path is an entity JSON:API exposes. */
|
|
18
|
+
jsonapi?: {
|
|
19
|
+
individual: string;
|
|
20
|
+
resourceName: string;
|
|
21
|
+
pathPrefix: string;
|
|
22
|
+
basePath: string;
|
|
23
|
+
entryPoint: string;
|
|
24
|
+
};
|
|
25
|
+
/** Redirects (redirect module) that led here, oldest first. */
|
|
26
|
+
redirect?: Array<{ from: string; to: string; status: string }>;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedPath<T> {
|
|
31
|
+
route: TranslatedPath;
|
|
32
|
+
/** The entity at the path; NULL when the path is not a JSON:API entity. */
|
|
33
|
+
resource: T | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type PathOptions = Pick<ResourceRequestOptions, 'locale' | 'auth' | 'headers' | 'fetchOptions' | 'signal'>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves a site path (`/about-us`, `/node/1`) with the decoupled_router
|
|
40
|
+
* module. NULL when nothing is there; a 403 (unpublished, no access) throws.
|
|
41
|
+
*/
|
|
42
|
+
export async function translatePath(client: JsonApiClient, path: string, options: PathOptions = {}): Promise<TranslatedPath | null> {
|
|
43
|
+
try {
|
|
44
|
+
const response = await client.get<TranslatedPath>(client.siteUrl('/router/translate-path', undefined, options.locale), {
|
|
45
|
+
...options,
|
|
46
|
+
query: { params: { path, _format: 'json' } },
|
|
47
|
+
headers: { Accept: 'application/json', ...headersObject(options.headers) },
|
|
48
|
+
parse: 'json',
|
|
49
|
+
});
|
|
50
|
+
return response.data;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (isJsonApiError(error) && error.status === 404) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves a path and fetches the entity there, in two requests. The entity
|
|
62
|
+
* is fetched from the URL decoupled_router gives, which respects resource
|
|
63
|
+
* paths renamed with JSON:API Extras and the path's language.
|
|
64
|
+
*
|
|
65
|
+
* ```ts
|
|
66
|
+
* const page = await getResourceByPath<Node>(drupal, params.slug, { query: { include: ['uid'] } });
|
|
67
|
+
* if (!page) notFound();
|
|
68
|
+
* if (page.route.redirect?.length) redirect(page.route.redirect.at(-1)!.to);
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export async function getResourceByPath<T = Resource>(client: JsonApiClient, path: string, options: ResourceRequestOptions = {}): Promise<ResolvedPath<T> | null> {
|
|
72
|
+
const route = await translatePath(client, path, options);
|
|
73
|
+
if (!route) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!route.jsonapi?.individual) {
|
|
77
|
+
return { route, resource: null };
|
|
78
|
+
}
|
|
79
|
+
const response = await client.get<T>(route.jsonapi.individual, options);
|
|
80
|
+
return { route, resource: response.data };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function headersObject(headers: HeadersInit | undefined): Record<string, string> {
|
|
84
|
+
const result: Record<string, string> = {};
|
|
85
|
+
new Headers(headers).forEach((value, name) => {
|
|
86
|
+
result[name] = value;
|
|
87
|
+
});
|
|
88
|
+
return result;
|
|
89
|
+
}
|
package/src/views.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { JsonApiClient, Links, Meta, Resource, ResourceRequestOptions } from '@jsondrop/core';
|
|
2
|
+
|
|
3
|
+
export interface ViewOptions extends Omit<ResourceRequestOptions, 'query'> {
|
|
4
|
+
/** Exposed filters, by identifier. */
|
|
5
|
+
filters?: Record<string, string | string[]>;
|
|
6
|
+
/** Contextual filter values, in order. */
|
|
7
|
+
arguments?: string[];
|
|
8
|
+
/** Exposed sort. */
|
|
9
|
+
sort?: { by: string; order?: 'ASC' | 'DESC' };
|
|
10
|
+
/** Zero-based page of the view's pager. */
|
|
11
|
+
page?: number;
|
|
12
|
+
/** Other parameters (`include` works). */
|
|
13
|
+
params?: Record<string, string | string[]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ViewResult<T> {
|
|
17
|
+
data: T[];
|
|
18
|
+
/** Total result count, when the view has a pager. */
|
|
19
|
+
count: number | null;
|
|
20
|
+
meta: Meta;
|
|
21
|
+
links: Links;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Results of a view display (jsonapi_views module): the display's
|
|
26
|
+
* filters, sorts and pager, as JSON:API resources.
|
|
27
|
+
*/
|
|
28
|
+
export async function getView<T = Resource>(client: JsonApiClient, view: string, display: string, options: ViewOptions = {}): Promise<ViewResult<T>> {
|
|
29
|
+
const { filters, arguments: args, sort, page, params, ...rest } = options;
|
|
30
|
+
const query: Record<string, string | string[]> = { ...params };
|
|
31
|
+
for (const [name, value] of Object.entries(filters ?? {})) {
|
|
32
|
+
if (Array.isArray(value)) query[`views-filter[${name}][]`] = value;
|
|
33
|
+
else query[`views-filter[${name}]`] = value;
|
|
34
|
+
}
|
|
35
|
+
if (args?.length) query['views-argument[]'] = args;
|
|
36
|
+
if (sort) {
|
|
37
|
+
query['views-sort[sort_by]'] = sort.by;
|
|
38
|
+
if (sort.order) query['views-sort[sort_order]'] = sort.order;
|
|
39
|
+
}
|
|
40
|
+
if (page !== undefined) query.page = String(page);
|
|
41
|
+
|
|
42
|
+
const response = await client.get<T[]>(`/views/${encodeURIComponent(view)}/${encodeURIComponent(display)}`, { ...rest, query: { params: query } });
|
|
43
|
+
const count = response.meta.count;
|
|
44
|
+
return {
|
|
45
|
+
data: response.data ?? [],
|
|
46
|
+
count: count === undefined || count === null ? null : Number(count),
|
|
47
|
+
meta: response.meta,
|
|
48
|
+
links: response.links,
|
|
49
|
+
};
|
|
50
|
+
}
|