@nuasite/cms-client 0.43.0-beta.4

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/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @nuasite/cms-client
2
+
3
+ Headless TypeScript SDK for the Nua CMS. Zero React/DOM-framework coupling — just
4
+ a typed `fetch` client over the cms-sidecar `/cms/v1` HTTP contract plus the pure
5
+ entry-draft form model.
6
+
7
+ Build any collections UI on top of it:
8
+
9
+ ```ts
10
+ import {
11
+ createClient,
12
+ draftFromEntry,
13
+ setDraftField,
14
+ } from '@nuasite/cms-client'
15
+
16
+ const client = createClient('/app/project/acme/session/123/cms') // host adds /cms/v1
17
+ const collections = await client.getCollections()
18
+ const entry = await client.getEntry('posts', 'hello-world')
19
+ ```
20
+
21
+ ## What's here
22
+
23
+ - **`createClient(apiBase)`** → `CmsClient`: `getProject`/`getCollections`/`getEntries`/`getEntry`
24
+ plus mutations (`updateEntry` with `409` conflict result, `createEntry`, `deleteEntry`,
25
+ `renameEntry`, array item ops) and media (`listMedia`/`uploadMedia`/`deleteMedia`,
26
+ degrades to `501` when the sidecar has no adapter — see `isMediaUnavailable`).
27
+ - **`CmsClientError`** — carries the sidecar error `code` (`unauthorized`/`forbidden`/`not_found`/…).
28
+ - **Form model** — `draftFromEntry`, `draftForCreate`, `draftFromServerFrontmatter`,
29
+ `setDraftField`, `coerceInput`, `parseWireValue`, and `valueTo*` readers. Pure,
30
+ unit-testable wire ↔ native mapping for the entry editor.
31
+
32
+ The structural contract (collections/fields/entries/media) is re-used 1:1 from
33
+ [`@nuasite/cms-types`](../cms-types). The default React UI built on this SDK is
34
+ [`@nuasite/collections-admin`](../collections-admin).
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Typed client over the cms-sidecar `/cms/v1` HTTP contract (reads + mutations).
3
+ *
4
+ * The host (webmaster BFF, or a local dev proxy in F7) mounts the sidecar under
5
+ * an `apiBase` and adds the `/cms/v1` prefix itself — so this client requests
6
+ * `${apiBase}/project`, `${apiBase}/collections`, etc. (never `/cms/v1/...`).
7
+ *
8
+ * The structural model (collections/entries/fields) is reused 1:1 from
9
+ * `@nuasite/cms-types`. The thin HTTP envelope (project model, sparse entries
10
+ * list, error codes, mutation bodies, conflict response) mirrors the sidecar's
11
+ * wire types; it is declared here because those types are not part of the
12
+ * `@nuasite/cms-types` contract surface.
13
+ */
14
+ import type { CollectionDefinition, CollectionEntry, CollectionEntryInfo, MediaListResult, MediaUploadResult, MutationResult } from '@nuasite/cms-types';
15
+ /** Stable error codes the sidecar exposes, each mapped to an HTTP status. */
16
+ export type CmsErrorCode = 'not_found' | 'conflict' | 'validation' | 'parse_error' | 'io_error' | 'unsupported' | 'unauthorized';
17
+ /** JSON body returned for every non-2xx response that is not a conflict. */
18
+ export interface CmsApiError {
19
+ error: string;
20
+ code: CmsErrorCode;
21
+ sourcePath?: string;
22
+ }
23
+ /** A static page route discovered under `src/pages` (pathname-only). */
24
+ export interface CmsPageEntry {
25
+ pathname: string;
26
+ title?: string;
27
+ }
28
+ /** Features the sidecar advertises so the UI can degrade gracefully. */
29
+ export interface CmsCapabilities {
30
+ coreVersion: string;
31
+ features: string[];
32
+ }
33
+ /** `GET /project` — the whole structural model in one call. */
34
+ export interface CmsProjectModel {
35
+ collections: CollectionDefinition[];
36
+ pages: CmsPageEntry[];
37
+ capabilities: CmsCapabilities;
38
+ }
39
+ /** `GET …/entries` — projected entries plus an opaque continuation cursor. */
40
+ export interface CmsEntriesListResult {
41
+ entries: CollectionEntryInfo[];
42
+ cursor?: string;
43
+ hasMore: boolean;
44
+ }
45
+ /**
46
+ * `409` body for a `PATCH` whose `baseHash` no longer matches disk (an agent or a
47
+ * human wrote in between). Carries the current server version so the UI can offer
48
+ * "use server" vs "use ours". Mirrors the sidecar `ConflictResponse`.
49
+ */
50
+ export interface CmsConflict {
51
+ code: 'conflict';
52
+ serverHash: string;
53
+ /** Raw (non-stringified) server frontmatter — unlike the line-keyed GET-detail shape. */
54
+ serverFrontmatter: Record<string, unknown>;
55
+ serverBody?: string;
56
+ }
57
+ /** `PATCH …/entries/:slug` — frontmatter keys are merged (not replaced). */
58
+ export interface UpdateEntryInput {
59
+ frontmatter?: Record<string, unknown>;
60
+ body?: string;
61
+ /** Hash of the source the client edited; drives optimistic concurrency. */
62
+ baseHash?: string;
63
+ }
64
+ export interface CreateEntryInput {
65
+ slug: string;
66
+ frontmatter: Record<string, unknown>;
67
+ body?: string;
68
+ /** File extension override for data collections (e.g. 'json', 'yaml'). */
69
+ fileExtension?: string;
70
+ }
71
+ /** Context passed to media operations so uploads can be filed against an entry/field. */
72
+ export interface MediaContext {
73
+ collection?: string;
74
+ entry?: string;
75
+ field?: string;
76
+ /** Subfolder under the media root. */
77
+ folder?: string;
78
+ }
79
+ /**
80
+ * Either a successful `MutationResult` or a `409` conflict the caller must
81
+ * resolve. Returned (not thrown) by `updateEntry` so the editor can branch
82
+ * without exception flow.
83
+ */
84
+ export type UpdateEntryResult = {
85
+ status: 'ok';
86
+ result: MutationResult;
87
+ } | {
88
+ status: 'conflict';
89
+ conflict: CmsConflict;
90
+ };
91
+ /**
92
+ * Thrown for any non-2xx response. Carries the parsed sidecar error code so the
93
+ * UI can distinguish auth failures (`unauthorized`/`forbidden`) from a missing
94
+ * collection/entry (`not_found`) or a generic failure.
95
+ */
96
+ export declare class CmsClientError extends Error {
97
+ readonly status: number;
98
+ readonly code: CmsErrorCode | 'forbidden' | 'unknown';
99
+ constructor(status: number, code: CmsErrorCode | 'forbidden' | 'unknown', message: string);
100
+ /** Session cookie missing/expired upstream — the user must re-authenticate. */
101
+ get isUnauthorized(): boolean;
102
+ /** Authenticated but lacks access to this project. */
103
+ get isForbidden(): boolean;
104
+ get isNotFound(): boolean;
105
+ }
106
+ export interface GetEntriesOptions {
107
+ /** "slug,title" | "*" ; absent = light header (slug/title/draft/pathname). */
108
+ fields?: string;
109
+ /** Draft filter — defaults to `'false'` (published only) on the sidecar. */
110
+ draft?: 'true' | 'false' | 'all';
111
+ /** Opaque continuation cursor from a previous page's `cursor`. */
112
+ cursor?: string;
113
+ limit?: number;
114
+ }
115
+ export interface CmsClient {
116
+ getProject(): Promise<CmsProjectModel>;
117
+ getCollections(): Promise<CollectionDefinition[]>;
118
+ getEntries(collection: string, options?: GetEntriesOptions): Promise<CmsEntriesListResult>;
119
+ getEntry(collection: string, slug: string): Promise<CollectionEntry>;
120
+ /**
121
+ * Merge-patch an entry's frontmatter/body. Returns a discriminated result: a
122
+ * `409` is surfaced as `{ status: 'conflict' }` (not thrown) so the editor can
123
+ * open the conflict dialog. The new `baseHash` is on `result.sourceHash`.
124
+ */
125
+ updateEntry(collection: string, slug: string, input: UpdateEntryInput): Promise<UpdateEntryResult>;
126
+ createEntry(collection: string, input: CreateEntryInput): Promise<MutationResult>;
127
+ deleteEntry(collection: string, slug: string): Promise<MutationResult>;
128
+ renameEntry(collection: string, slug: string, to: string): Promise<MutationResult>;
129
+ addArrayItem(collection: string, slug: string, field: string, value: unknown, index?: number): Promise<MutationResult>;
130
+ removeArrayItem(collection: string, slug: string, field: string, index: number): Promise<MutationResult>;
131
+ listMedia(options?: {
132
+ folder?: string;
133
+ cursor?: string;
134
+ limit?: number;
135
+ }): Promise<MediaListResult>;
136
+ uploadMedia(file: File, context?: MediaContext): Promise<MediaUploadResult>;
137
+ deleteMedia(id: string): Promise<{
138
+ success: boolean;
139
+ error?: string;
140
+ }>;
141
+ }
142
+ /**
143
+ * Whether a thrown `CmsClientError` means "media is not available" — the deployed
144
+ * sidecar may have no media adapter wired (`501 unsupported`). The picker uses
145
+ * this to degrade gracefully instead of surfacing a hard error.
146
+ */
147
+ export declare function isMediaUnavailable(error: unknown): boolean;
148
+ export declare function createClient(apiBase: string): CmsClient;
149
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Typed client over the cms-sidecar `/cms/v1` HTTP contract (reads + mutations).
3
+ *
4
+ * The host (webmaster BFF, or a local dev proxy in F7) mounts the sidecar under
5
+ * an `apiBase` and adds the `/cms/v1` prefix itself — so this client requests
6
+ * `${apiBase}/project`, `${apiBase}/collections`, etc. (never `/cms/v1/...`).
7
+ *
8
+ * The structural model (collections/entries/fields) is reused 1:1 from
9
+ * `@nuasite/cms-types`. The thin HTTP envelope (project model, sparse entries
10
+ * list, error codes, mutation bodies, conflict response) mirrors the sidecar's
11
+ * wire types; it is declared here because those types are not part of the
12
+ * `@nuasite/cms-types` contract surface.
13
+ */
14
+ /** HTTP status the sidecar uses for an optimistic-concurrency conflict. */
15
+ const STATUS_CONFLICT = 409;
16
+ // ============================================================================
17
+ // Client error
18
+ // ============================================================================
19
+ /**
20
+ * Thrown for any non-2xx response. Carries the parsed sidecar error code so the
21
+ * UI can distinguish auth failures (`unauthorized`/`forbidden`) from a missing
22
+ * collection/entry (`not_found`) or a generic failure.
23
+ */
24
+ export class CmsClientError extends Error {
25
+ status;
26
+ code;
27
+ constructor(status, code, message) {
28
+ super(message);
29
+ this.status = status;
30
+ this.code = code;
31
+ this.name = 'CmsClientError';
32
+ }
33
+ /** Session cookie missing/expired upstream — the user must re-authenticate. */
34
+ get isUnauthorized() {
35
+ return this.code === 'unauthorized' || this.status === 401;
36
+ }
37
+ /** Authenticated but lacks access to this project. */
38
+ get isForbidden() {
39
+ return this.code === 'forbidden' || this.status === 403;
40
+ }
41
+ get isNotFound() {
42
+ return this.code === 'not_found' || this.status === 404;
43
+ }
44
+ }
45
+ // ============================================================================
46
+ // Client
47
+ // ============================================================================
48
+ function isApiError(value) {
49
+ return isRecord(value)
50
+ && typeof value.error === 'string'
51
+ && typeof value.code === 'string';
52
+ }
53
+ /** Narrow `unknown` to a record so property reads typecheck without casts. */
54
+ function isRecord(value) {
55
+ return typeof value === 'object' && value !== null;
56
+ }
57
+ function isConflict(value) {
58
+ if (!isRecord(value))
59
+ return false;
60
+ return value.code === 'conflict'
61
+ && typeof value.serverHash === 'string'
62
+ && isRecord(value.serverFrontmatter);
63
+ }
64
+ const KNOWN_ERROR_CODES = [
65
+ 'not_found',
66
+ 'conflict',
67
+ 'validation',
68
+ 'parse_error',
69
+ 'io_error',
70
+ 'unsupported',
71
+ 'unauthorized',
72
+ ];
73
+ function isErrorCode(value) {
74
+ return KNOWN_ERROR_CODES.includes(value);
75
+ }
76
+ /**
77
+ * Whether a thrown `CmsClientError` means "media is not available" — the deployed
78
+ * sidecar may have no media adapter wired (`501 unsupported`). The picker uses
79
+ * this to degrade gracefully instead of surfacing a hard error.
80
+ */
81
+ export function isMediaUnavailable(error) {
82
+ return error instanceof CmsClientError && (error.status === 501 || error.code === 'unsupported');
83
+ }
84
+ export function createClient(apiBase) {
85
+ // Normalise: drop a trailing slash so `${base}${path}` joins cleanly.
86
+ const base = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase;
87
+ async function request(path) {
88
+ const response = await fetch(`${base}${path}`, {
89
+ method: 'GET',
90
+ credentials: 'include',
91
+ headers: { accept: 'application/json' },
92
+ });
93
+ if (!response.ok) {
94
+ throw await toError(response);
95
+ }
96
+ // Successful responses are always JSON in the read-only surface.
97
+ const value = await response.json();
98
+ return value;
99
+ }
100
+ async function toError(response) {
101
+ // 403 is produced by the BFF (project scope), not the sidecar, so it has no
102
+ // sidecar `code`; `errorFromBody` surfaces it as a distinct `forbidden`.
103
+ const body = await response.json().catch(() => null);
104
+ return errorFromBody(response.status, body);
105
+ }
106
+ function errorMessageFromBody(body, fallback) {
107
+ if (isApiError(body))
108
+ return body.error;
109
+ if (isRecord(body)) {
110
+ const err = body.error;
111
+ if (isRecord(err) && typeof err.message === 'string')
112
+ return err.message;
113
+ }
114
+ return fallback;
115
+ }
116
+ /** Build a `CmsClientError` from an already-parsed body (no re-read of the stream). */
117
+ function errorFromBody(status, body) {
118
+ if (status === 403) {
119
+ return new CmsClientError(403, 'forbidden', errorMessageFromBody(body, 'You do not have access to this project.'));
120
+ }
121
+ if (isApiError(body) && isErrorCode(body.code)) {
122
+ return new CmsClientError(status, body.code, body.error);
123
+ }
124
+ if (status === 401) {
125
+ return new CmsClientError(401, 'unauthorized', 'Your session has expired. Please reload.');
126
+ }
127
+ return new CmsClientError(status, 'unknown', errorMessageFromBody(body, `Request failed (${status})`));
128
+ }
129
+ function mutationInit(method, body) {
130
+ const init = {
131
+ method,
132
+ credentials: 'include',
133
+ headers: { accept: 'application/json' },
134
+ };
135
+ if (body !== undefined) {
136
+ init.body = JSON.stringify(body);
137
+ init.headers = { accept: 'application/json', 'content-type': 'application/json' };
138
+ }
139
+ return init;
140
+ }
141
+ /**
142
+ * Send a JSON-body mutation (POST/PATCH/DELETE). Throws `CmsClientError` on any
143
+ * non-2xx — used by the mutations that have no conflict branch. The
144
+ * conflict-aware update has its own path below.
145
+ */
146
+ async function mutate(path, method, body) {
147
+ const response = await fetch(`${base}${path}`, mutationInit(method, body));
148
+ if (!response.ok)
149
+ throw await toError(response);
150
+ // Mutation responses are documented JSON; the asserted shape is trusted
151
+ // (`response.json()` widens to the declared `T`, mirroring `request`).
152
+ const value = await response.json();
153
+ return value;
154
+ }
155
+ function entryPath(collection, slug) {
156
+ return `/collections/${encodeURIComponent(collection)}/entries/${encodeURIComponent(slug)}`;
157
+ }
158
+ return {
159
+ getProject() {
160
+ return request('/project');
161
+ },
162
+ getCollections() {
163
+ return request('/collections');
164
+ },
165
+ getEntries(collection, options = {}) {
166
+ const params = new URLSearchParams();
167
+ if (options.fields !== undefined)
168
+ params.set('fields', options.fields);
169
+ if (options.draft !== undefined)
170
+ params.set('draft', options.draft);
171
+ if (options.cursor !== undefined)
172
+ params.set('cursor', options.cursor);
173
+ if (options.limit !== undefined)
174
+ params.set('limit', String(options.limit));
175
+ const query = params.toString();
176
+ const suffix = query === '' ? '' : `?${query}`;
177
+ return request(`/collections/${encodeURIComponent(collection)}/entries${suffix}`);
178
+ },
179
+ getEntry(collection, slug) {
180
+ return request(entryPath(collection, slug));
181
+ },
182
+ async updateEntry(collection, slug, input) {
183
+ const response = await fetch(`${base}${entryPath(collection, slug)}`, mutationInit('PATCH', input));
184
+ // A `409` carries the server version; parse and return it for the dialog.
185
+ if (response.status === STATUS_CONFLICT) {
186
+ const body = await response.json().catch(() => null);
187
+ if (isConflict(body))
188
+ return { status: 'conflict', conflict: body };
189
+ throw errorFromBody(response.status, body);
190
+ }
191
+ if (!response.ok)
192
+ throw await toError(response);
193
+ const result = await response.json();
194
+ return { status: 'ok', result };
195
+ },
196
+ createEntry(collection, input) {
197
+ return mutate(`/collections/${encodeURIComponent(collection)}/entries`, 'POST', input);
198
+ },
199
+ deleteEntry(collection, slug) {
200
+ return mutate(entryPath(collection, slug), 'DELETE');
201
+ },
202
+ renameEntry(collection, slug, to) {
203
+ return mutate(`${entryPath(collection, slug)}/rename`, 'POST', { to });
204
+ },
205
+ addArrayItem(collection, slug, field, value, index) {
206
+ const body = index === undefined ? { field, value } : { field, value, index };
207
+ return mutate(`${entryPath(collection, slug)}/array`, 'POST', body);
208
+ },
209
+ removeArrayItem(collection, slug, field, index) {
210
+ return mutate(`${entryPath(collection, slug)}/array`, 'DELETE', { field, index });
211
+ },
212
+ listMedia(options = {}) {
213
+ const params = new URLSearchParams();
214
+ if (options.folder !== undefined)
215
+ params.set('folder', options.folder);
216
+ if (options.cursor !== undefined)
217
+ params.set('cursor', options.cursor);
218
+ if (options.limit !== undefined)
219
+ params.set('limit', String(options.limit));
220
+ const query = params.toString();
221
+ return request(`/media${query === '' ? '' : `?${query}`}`);
222
+ },
223
+ async uploadMedia(file, context = {}) {
224
+ // The sidecar reads upload context (collection/entry/field/folder) from the
225
+ // query string; the file rides in multipart form data under `file`.
226
+ const params = new URLSearchParams();
227
+ if (context.collection !== undefined)
228
+ params.set('collection', context.collection);
229
+ if (context.entry !== undefined)
230
+ params.set('entry', context.entry);
231
+ if (context.field !== undefined)
232
+ params.set('field', context.field);
233
+ if (context.folder !== undefined)
234
+ params.set('folder', context.folder);
235
+ const query = params.toString();
236
+ const form = new FormData();
237
+ form.append('file', file);
238
+ const response = await fetch(`${base}/media${query === '' ? '' : `?${query}`}`, {
239
+ method: 'POST',
240
+ credentials: 'include',
241
+ headers: { accept: 'application/json' },
242
+ body: form,
243
+ });
244
+ if (!response.ok)
245
+ throw await toError(response);
246
+ const result = await response.json();
247
+ return result;
248
+ },
249
+ deleteMedia(id) {
250
+ return mutate(`/media/${encodeURIComponent(id)}`, 'DELETE');
251
+ },
252
+ };
253
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure draft model + field coercion for the entry editor (cms-headless F3.2).
3
+ *
4
+ * The sidecar speaks two slightly different frontmatter shapes:
5
+ * - `GET …/entries/:slug` returns `frontmatter: Record<string, { value: string; line: number }>`,
6
+ * where `value` is already stringified (objects/arrays are JSON).
7
+ * - `PATCH …` accepts `frontmatter?: Record<string, unknown>` of *native* values (merged), and a
8
+ * `409` `serverFrontmatter` is likewise native (not stringified).
9
+ *
10
+ * The editor works on a single native draft (`EntryDraft`): `frontmatter` is a
11
+ * `Record<string, unknown>` of native JS values keyed by field name, plus the
12
+ * markdown `body`. This module converts to/from the wire and coerces raw input
13
+ * (form strings) into the native value a `FieldType` expects. Keeping it pure
14
+ * (no React/DOM) makes the mapping unit-testable.
15
+ */
16
+ import type { CollectionEntry, FieldDefinition, FieldType } from '@nuasite/cms-types';
17
+ /** The editor's in-memory state: native frontmatter values + the markdown body. */
18
+ export interface EntryDraft {
19
+ frontmatter: Record<string, unknown>;
20
+ body: string;
21
+ }
22
+ /**
23
+ * Parse one stringified frontmatter `value` (from `GET …/entries/:slug`) into the
24
+ * native value a field of `type` expects. Structural types (object/array) and
25
+ * unknowns fall back to a best-effort `JSON.parse`; scalars are coerced per type.
26
+ */
27
+ export declare function parseWireValue(type: FieldType, raw: string): unknown;
28
+ /**
29
+ * Build a native draft from a loaded entry, driven by the collection's fields.
30
+ * Frontmatter keys present on the entry but absent from the inferred schema are
31
+ * preserved verbatim (as raw strings) so a save never silently drops them.
32
+ */
33
+ export declare function draftFromEntry(entry: CollectionEntry, fields: FieldDefinition[]): EntryDraft;
34
+ /**
35
+ * Build a fresh draft for a create form from the collection's fields, seeding
36
+ * each field with its `defaultValue` (when present) or a type-appropriate blank.
37
+ */
38
+ export declare function draftForCreate(fields: FieldDefinition[]): EntryDraft;
39
+ /** A type-appropriate empty value used to seed create forms. */
40
+ export declare function blankValue(type: FieldType): unknown;
41
+ /**
42
+ * Adopt a server-provided native frontmatter map (from a `409` `serverFrontmatter`)
43
+ * into a draft, re-coercing per field where a definition exists.
44
+ */
45
+ export declare function draftFromServerFrontmatter(serverFrontmatter: Record<string, unknown>, serverBody: string | undefined, fields: FieldDefinition[]): EntryDraft;
46
+ /**
47
+ * Coerce a raw form-control string into the native value a field expects. Used by
48
+ * the widgets, whose `<input>` values are always strings.
49
+ */
50
+ export declare function coerceInput(type: FieldType, raw: string): unknown;
51
+ /** Render a native value back to a string for a text/number/date/select control. */
52
+ export declare function valueToInput(value: unknown): string;
53
+ /** Read a value as a boolean for toggle widgets, tolerating string encodings. */
54
+ export declare function valueToBoolean(value: unknown): boolean;
55
+ /** Read a value as an array of items for repeater widgets. */
56
+ export declare function valueToArray(value: unknown): unknown[];
57
+ /** Read a value as an object for nested-group widgets. */
58
+ export declare function valueToObject(value: unknown): Record<string, unknown>;
59
+ /** Immutably set a top-level frontmatter key in a draft. */
60
+ export declare function setDraftField(draft: EntryDraft, name: string, value: unknown): EntryDraft;
61
+ //# sourceMappingURL=form-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"form-model.d.ts","sourceRoot":"","sources":["../../src/form-model.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAErF,mFAAmF;AACnF,MAAM,WAAW,UAAU;IAC1B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IACpC,IAAI,EAAE,MAAM,CAAA;CACZ;AAMD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAgBpE;AAcD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,eAAe,EAAE,GAAG,UAAU,CAQ5F;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,UAAU,CAWpE;AAED,gEAAgE;AAChE,wBAAgB,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAWnD;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CACzC,iBAAiB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC1C,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,MAAM,EAAE,eAAe,EAAE,GACvB,UAAU,CAUZ;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAkBjE;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKnD;AAED,iFAAiF;AACjF,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAItD;AAED,8DAA8D;AAC9D,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,EAAE,CAEtD;AAED,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAErE;AAED,4DAA4D;AAC5D,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,UAAU,CAEzF"}