@colixsystems/widget-sdk 0.37.0 → 0.39.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.
- package/README.md +27 -10
- package/dist/cli.js +11 -3
- package/dist/contract.cjs +107 -13
- package/dist/contract.js +107 -13
- package/dist/dev-shims.js +255 -0
- package/dist/devserver.js +640 -73
- package/dist/hooks.js +60 -31
- package/dist/index.d.ts +10 -10
- package/dist/index.js +1 -1
- package/dist/index.native.js +1 -1
- package/dist/webbundle.js +125 -0
- package/package.json +7 -2
package/dist/hooks.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// four-client mapping is obvious at a glance:
|
|
13
13
|
// - CORE — WidgetContext + non-data hooks (no domain client)
|
|
14
14
|
// - DATASTORE — ctx.datastore (@colixsystems/datastore-client)
|
|
15
|
-
// -
|
|
15
|
+
// - ASSETS — ctx.assets (@colixsystems/assets-client)
|
|
16
16
|
// - DIRECTORY — ctx.directory (@colixsystems/directory-client)
|
|
17
17
|
// - PAYMENTS — ctx.payments (@colixsystems/payments-client)
|
|
18
18
|
//
|
|
@@ -26,10 +26,18 @@ import React, {
|
|
|
26
26
|
useRef,
|
|
27
27
|
useState,
|
|
28
28
|
} from "react";
|
|
29
|
-
// REQ-L10N-WIDGET: the
|
|
30
|
-
// (contract.js). useI18n (lookup) and the backend seeder (write) both
|
|
31
|
-
// so the namespaced
|
|
32
|
-
|
|
29
|
+
// REQ-L10N-WIDGET / REQ-L10N-SHARED: the translation key formats live in ONE
|
|
30
|
+
// place (contract.js). useI18n (lookup) and the backend seeder (write) both
|
|
31
|
+
// call them so the namespaced keys can never drift between the two sides.
|
|
32
|
+
// `widgetTranslationKey` builds the per-widget key (`widget.<id>.<key>`);
|
|
33
|
+
// `sharedTranslationKey` builds the tenant-wide predefined key
|
|
34
|
+
// (`shared.<key>`); `isSharedTranslationKey` tells the hook whether a bare key
|
|
35
|
+
// is one of the predefined shared keys and so should try the shared namespace.
|
|
36
|
+
import {
|
|
37
|
+
widgetTranslationKey,
|
|
38
|
+
sharedTranslationKey,
|
|
39
|
+
isSharedTranslationKey,
|
|
40
|
+
} from "./contract.js";
|
|
33
41
|
|
|
34
42
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
35
43
|
const HostWidgetContext = createContext(null);
|
|
@@ -240,7 +248,27 @@ export function useI18n() {
|
|
|
240
248
|
return scoped;
|
|
241
249
|
}
|
|
242
250
|
}
|
|
243
|
-
// 2)
|
|
251
|
+
// 2) REQ-L10N-SHARED: for a predefined SHARED key, try the tenant-wide
|
|
252
|
+
// `shared.<key>` namespace next. This is what lets identical
|
|
253
|
+
// default-widget strings ("Submit", "Cancel", …) translate ONCE: a
|
|
254
|
+
// widget that has no per-instance `widget.<id>.<key>` override
|
|
255
|
+
// inherits the shared value. The per-widget step above runs FIRST,
|
|
256
|
+
// so an author override of a single instance still wins for that
|
|
257
|
+
// instance. Only the predefined keys are tried here — an
|
|
258
|
+
// author-invented bare key is never silently shared.
|
|
259
|
+
if (isSharedTranslationKey(key)) {
|
|
260
|
+
const shared = sharedTranslationKey(key);
|
|
261
|
+
const scoped = hostT(shared);
|
|
262
|
+
if (
|
|
263
|
+
typeof scoped === "string" &&
|
|
264
|
+
scoped.length > 0 &&
|
|
265
|
+
scoped !== shared &&
|
|
266
|
+
!scoped.startsWith("{{t:")
|
|
267
|
+
) {
|
|
268
|
+
return scoped;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// 3) Fall back to the raw key (shared app keys + widgets with no
|
|
244
272
|
// manifest translations — unchanged from pre-1.10 behaviour).
|
|
245
273
|
const out = hostT(key, fallback);
|
|
246
274
|
if (typeof out === "string" && out.length > 0 && out !== key) {
|
|
@@ -1029,59 +1057,60 @@ export function useDatastoreSubscription(table, handlers, options) {
|
|
|
1029
1057
|
}
|
|
1030
1058
|
|
|
1031
1059
|
/* ============================================================================
|
|
1032
|
-
*
|
|
1060
|
+
* ASSETS CLIENT — ctx.assets (@colixsystems/assets-client)
|
|
1033
1061
|
*
|
|
1034
|
-
*
|
|
1062
|
+
* The Asset Manager: resolve a tenant asset (image / audio / video / doc) by
|
|
1063
|
+
* id to a browser-usable URL. Covers: useAsset.
|
|
1035
1064
|
* ==========================================================================*/
|
|
1036
1065
|
|
|
1037
1066
|
/**
|
|
1038
|
-
* Stateful
|
|
1067
|
+
* Stateful asset resolver hook. Returns { url, asset, loading, error,
|
|
1039
1068
|
* refetch }.
|
|
1040
1069
|
*
|
|
1041
|
-
* The host injects a `@colixsystems/
|
|
1042
|
-
* FLATTENED so
|
|
1043
|
-
* which resolves to `{ url, ...meta }` — the returned
|
|
1070
|
+
* The host injects a `@colixsystems/assets-client` instance at `ctx.assets`,
|
|
1071
|
+
* FLATTENED so asset ops are top-level. We call `ctx.assets.get(assetId)`,
|
|
1072
|
+
* which resolves to `{ url, ...meta }` — the returned asset already carries
|
|
1044
1073
|
* an absolute URL so the widget can drop it straight into an `<Image
|
|
1045
1074
|
* source>` without knowing where the API lives. A missing/soft-deleted
|
|
1046
1075
|
* asset surfaces as `{ url: null, error: <DatastoreError NOT_FOUND> }`.
|
|
1047
1076
|
*
|
|
1048
|
-
* When `
|
|
1077
|
+
* When `assetId` is falsy the hook collapses to { url: null, asset: null,
|
|
1049
1078
|
* loading: false, error: null, refetch } without a network round-trip,
|
|
1050
1079
|
* so a widget rendering before the author has bound an asset stays
|
|
1051
1080
|
* loop-free.
|
|
1052
1081
|
*/
|
|
1053
|
-
export function
|
|
1054
|
-
const ctx = useWidgetContextOrThrow("
|
|
1055
|
-
if (!ctx.
|
|
1056
|
-
throw new Error("
|
|
1082
|
+
export function useAsset(assetId) {
|
|
1083
|
+
const ctx = useWidgetContextOrThrow("useAsset");
|
|
1084
|
+
if (!ctx.assets || typeof ctx.assets.get !== "function") {
|
|
1085
|
+
throw new Error("useAsset: host did not inject an assets client");
|
|
1057
1086
|
}
|
|
1058
|
-
const ready = Boolean(
|
|
1059
|
-
const [
|
|
1087
|
+
const ready = Boolean(assetId);
|
|
1088
|
+
const [asset, setAsset] = useState(null);
|
|
1060
1089
|
const [loading, setLoading] = useState(ready);
|
|
1061
1090
|
const [error, setError] = useState(null);
|
|
1062
1091
|
|
|
1063
|
-
const
|
|
1064
|
-
const getRef = useRef(ctx.
|
|
1065
|
-
|
|
1066
|
-
getRef.current = ctx.
|
|
1092
|
+
const assetIdRef = useRef(assetId);
|
|
1093
|
+
const getRef = useRef(ctx.assets.get);
|
|
1094
|
+
assetIdRef.current = assetId;
|
|
1095
|
+
getRef.current = ctx.assets.get;
|
|
1067
1096
|
|
|
1068
1097
|
const runRef = useRef(0);
|
|
1069
1098
|
|
|
1070
1099
|
const doFetch = useCallback(async () => {
|
|
1071
1100
|
const myRun = ++runRef.current;
|
|
1072
|
-
const id =
|
|
1101
|
+
const id = assetIdRef.current;
|
|
1073
1102
|
if (!id) {
|
|
1074
1103
|
setLoading(false);
|
|
1075
1104
|
setError(null);
|
|
1076
|
-
|
|
1105
|
+
setAsset(null);
|
|
1077
1106
|
return;
|
|
1078
1107
|
}
|
|
1079
1108
|
setLoading(true);
|
|
1080
1109
|
setError(null);
|
|
1081
1110
|
try {
|
|
1082
|
-
const
|
|
1111
|
+
const a = await getRef.current(id);
|
|
1083
1112
|
if (runRef.current !== myRun) return;
|
|
1084
|
-
|
|
1113
|
+
setAsset(a || null);
|
|
1085
1114
|
setLoading(false);
|
|
1086
1115
|
} catch (err) {
|
|
1087
1116
|
if (runRef.current !== myRun) return;
|
|
@@ -1093,17 +1122,17 @@ export function useFile(fileId) {
|
|
|
1093
1122
|
useEffect(() => {
|
|
1094
1123
|
doFetch();
|
|
1095
1124
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1096
|
-
}, [
|
|
1125
|
+
}, [assetId]);
|
|
1097
1126
|
|
|
1098
1127
|
const refetch = useCallback(async () => {
|
|
1099
1128
|
await doFetch();
|
|
1100
1129
|
}, [doFetch]);
|
|
1101
1130
|
|
|
1102
1131
|
const url =
|
|
1103
|
-
|
|
1104
|
-
?
|
|
1132
|
+
asset && typeof asset.url === "string" && asset.url.length > 0
|
|
1133
|
+
? asset.url
|
|
1105
1134
|
: null;
|
|
1106
|
-
return { url,
|
|
1135
|
+
return { url, asset, loading, error, refetch };
|
|
1107
1136
|
}
|
|
1108
1137
|
|
|
1109
1138
|
/* ============================================================================
|
package/dist/index.d.ts
CHANGED
|
@@ -375,13 +375,13 @@ export interface DirectoryClient {
|
|
|
375
375
|
}
|
|
376
376
|
|
|
377
377
|
/**
|
|
378
|
-
* Structural shape of the injected Asset-Manager
|
|
378
|
+
* Structural shape of the injected Asset-Manager client (`ctx.assets`).
|
|
379
379
|
* Slimmed to the three top-level ops the host injects — `get` / `list` /
|
|
380
|
-
* `upload`. Backs `
|
|
381
|
-
*
|
|
380
|
+
* `upload`. Backs `useAsset`, which calls `ctx.assets.get(id)`. The returned
|
|
381
|
+
* asset carries an absolute `url` safe to drop into `<Image source>`.
|
|
382
382
|
*/
|
|
383
|
-
export interface
|
|
384
|
-
get(
|
|
383
|
+
export interface AssetsClient {
|
|
384
|
+
get(assetId: string): Promise<unknown>;
|
|
385
385
|
list(query?: Record<string, unknown>): Promise<ListEnvelope>;
|
|
386
386
|
upload(formData: unknown): Promise<unknown>;
|
|
387
387
|
}
|
|
@@ -431,8 +431,8 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
431
431
|
datastore: DatastoreClient;
|
|
432
432
|
/** Injected @colixsystems/directory-client (users + groups + invites). */
|
|
433
433
|
directory: DirectoryClient;
|
|
434
|
-
/** Injected
|
|
435
|
-
|
|
434
|
+
/** Injected @colixsystems/assets-client: { get, list, upload }. */
|
|
435
|
+
assets: AssetsClient;
|
|
436
436
|
/** Injected @colixsystems/payments-client. */
|
|
437
437
|
payments: PaymentsClient;
|
|
438
438
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
@@ -457,7 +457,7 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
457
457
|
/**
|
|
458
458
|
* The widget's component receives the author-authored props **as React
|
|
459
459
|
* props** — the same shape and calling convention every built-in widget
|
|
460
|
-
* uses (`<
|
|
460
|
+
* uses (`<ChartWidget tableId={...} groupByField={...} />`). Everything
|
|
461
461
|
* else from `WidgetContext` (datastore, user, workspace, navigation,
|
|
462
462
|
* i18n, …) is reachable via the SDK hooks (`useDatastoreMutation`,
|
|
463
463
|
* `useDatastoreQuery`, `useTheme`, `useI18n`, `useWidgetEvent`), which
|
|
@@ -742,9 +742,9 @@ export function useChildRenderer(): {
|
|
|
742
742
|
*/
|
|
743
743
|
export const WidgetTree: (props: { node: unknown }) => unknown;
|
|
744
744
|
|
|
745
|
-
export function
|
|
745
|
+
export function useAsset(assetId: string | null | undefined): {
|
|
746
746
|
url: string | null;
|
|
747
|
-
|
|
747
|
+
asset: {
|
|
748
748
|
id: string;
|
|
749
749
|
url: string;
|
|
750
750
|
stored_filename?: string;
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// REQ-WSDK-PLATFORM bundle (sc-1064): the SINGLE web-entry bundler shared by the
|
|
2
|
+
// publish packer (`scripts/pack-first-party-widget.mjs`) and the dev server
|
|
3
|
+
// (`devserver.js`). Both paths feed a widget's WEB entry through this so what
|
|
4
|
+
// you `dev` is byte-shape-identical to what ships (CLAUDE.md §3, one source).
|
|
5
|
+
//
|
|
6
|
+
// Why a bundler at all (vs. the sucrase transpile the native entry still uses):
|
|
7
|
+
// the Studio's runtime loader resolves bare specifiers only for the host's OWN
|
|
8
|
+
// shimmed set (`dev-shims.hostExternalSpecifiers()` — react family, react-dom,
|
|
9
|
+
// the SDK, and a few vetted packages). A transpile-only web entry leaves
|
|
10
|
+
// `import … from "react-leaflet"` / `"leaflet"` / `"leaflet/dist/leaflet.css"`
|
|
11
|
+
// / the marker PNGs bare, and the loader's `findUnresolvedBareImports` guard
|
|
12
|
+
// rejects the bundle ("unresolvable bare imports: react-leaflet, leaflet, …").
|
|
13
|
+
// esbuild inlines all of those while leaving the host-resolved set external.
|
|
14
|
+
//
|
|
15
|
+
// What's externalised vs. inlined:
|
|
16
|
+
// * external = `hostExternalSpecifiers()` — the host injects its OWN React,
|
|
17
|
+
// react-dom, SDK, and vetted packages at runtime; inlining a second copy of
|
|
18
|
+
// React/react-dom would break the shared-instance invariant (mismatched
|
|
19
|
+
// renderer). These stay as bare `import`s the loader rewrites to blob shims.
|
|
20
|
+
// * inlined = everything else: `react-leaflet`, `leaflet`, the marker PNGs
|
|
21
|
+
// (→ data URLs), the `leaflet/dist/leaflet.css` side-effect import (→ a JS
|
|
22
|
+
// module that injects a <style> at runtime).
|
|
23
|
+
//
|
|
24
|
+
// esbuild is a LAZY, optional dependency — imported only when this module's
|
|
25
|
+
// `bundleWebEntry` runs (the `dev`/pack paths), never by the published SDK
|
|
26
|
+
// runtime. Same pattern as `loadTransform()`'s `sucrase` import in devserver.js.
|
|
27
|
+
|
|
28
|
+
import { hostExternalSpecifiers } from "./dev-shims.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Lazily resolve esbuild's `build`. Kept out of the module's static imports so
|
|
32
|
+
* the published SDK runtime never forces the dependency — only the dev/pack
|
|
33
|
+
* bundle paths reach it. Throws a friendly install hint when it's absent
|
|
34
|
+
* (mirrors `loadTransform`'s sucrase handling).
|
|
35
|
+
*
|
|
36
|
+
* @returns {Promise<typeof import("esbuild")>}
|
|
37
|
+
*/
|
|
38
|
+
export async function loadEsbuild() {
|
|
39
|
+
try {
|
|
40
|
+
return await import("esbuild");
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"Bundling a widget's web entry needs the optional `esbuild` dependency.\n" +
|
|
44
|
+
"Install it in your widget project:\n" +
|
|
45
|
+
" npm install --save-dev esbuild",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// esbuild plugin: resolve `import "….css"` (a side-effect import) to a tiny JS
|
|
51
|
+
// module that injects the CSS through a runtime <style> element. The output
|
|
52
|
+
// bundle then carries NO `.css` import, and the styling still applies the
|
|
53
|
+
// moment the module is evaluated in the browser. Guarded by
|
|
54
|
+
// `typeof document !== "undefined"` so the same module is a no-op under SSR /
|
|
55
|
+
// the native runtime (which never loads the web entry, but belt-and-braces).
|
|
56
|
+
function cssInjectPlugin() {
|
|
57
|
+
return {
|
|
58
|
+
name: "appstudio-css-inject",
|
|
59
|
+
setup(build) {
|
|
60
|
+
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
|
61
|
+
const { readFile } = await import("node:fs/promises");
|
|
62
|
+
const css = await readFile(args.path, "utf8");
|
|
63
|
+
const contents =
|
|
64
|
+
"if (typeof document !== \"undefined\") {\n" +
|
|
65
|
+
" const __s = document.createElement(\"style\");\n" +
|
|
66
|
+
" __s.setAttribute(\"data-appstudio-widget-css\", \"1\");\n" +
|
|
67
|
+
` __s.textContent = ${JSON.stringify(css)};\n` +
|
|
68
|
+
" document.head.appendChild(__s);\n" +
|
|
69
|
+
"}\n" +
|
|
70
|
+
"export default undefined;\n";
|
|
71
|
+
return { contents, loader: "js" };
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bundle a widget's WEB entry into a single ESM string with the host-resolved
|
|
79
|
+
* specifiers left external. JSX is transformed with the automatic runtime (so
|
|
80
|
+
* the loader's react/jsx-runtime shims resolve it), `.png` assets become data
|
|
81
|
+
* URLs, and `.css` side-effect imports become runtime <style> injectors.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} opts
|
|
84
|
+
* @param {string} opts.entryPath absolute path to the web entry (e.g. the
|
|
85
|
+
* resolved `MapWidget.web.jsx`).
|
|
86
|
+
* @param {string[]} [opts.external] bare specifiers to leave external. Defaults
|
|
87
|
+
* to `hostExternalSpecifiers()` — the canonical host-resolved set.
|
|
88
|
+
* @param {string[]} [opts.nodePaths] extra `node_modules` roots esbuild should
|
|
89
|
+
* search when resolving the inlined deps (`react-leaflet`, `leaflet`, …). The
|
|
90
|
+
* packer passes the frontend's `node_modules` (where the vetted web packages
|
|
91
|
+
* are installed per the adding-vetted-packages skill) so the bundle resolves
|
|
92
|
+
* them no matter the invoking CWD.
|
|
93
|
+
* @returns {Promise<string>} the bundled ESM source.
|
|
94
|
+
*/
|
|
95
|
+
export async function bundleWebEntry({
|
|
96
|
+
entryPath,
|
|
97
|
+
external = hostExternalSpecifiers(),
|
|
98
|
+
nodePaths = [],
|
|
99
|
+
}) {
|
|
100
|
+
const esbuild = await loadEsbuild();
|
|
101
|
+
const result = await esbuild.build({
|
|
102
|
+
entryPoints: [entryPath],
|
|
103
|
+
bundle: true,
|
|
104
|
+
write: false,
|
|
105
|
+
format: "esm",
|
|
106
|
+
platform: "browser",
|
|
107
|
+
target: ["es2020"],
|
|
108
|
+
jsx: "automatic",
|
|
109
|
+
// Leave the host-resolved set as bare `import`s; the Studio loader rewrites
|
|
110
|
+
// them to blob shims at runtime (and Metro resolves them in the export).
|
|
111
|
+
external,
|
|
112
|
+
loader: { ".png": "dataurl", ".jpg": "dataurl", ".gif": "dataurl" },
|
|
113
|
+
plugins: [cssInjectPlugin()],
|
|
114
|
+
// esbuild walks up from the entry for node_modules by default; `nodePaths`
|
|
115
|
+
// adds extra roots so a packer invoked from the repo root still resolves the
|
|
116
|
+
// vetted web packages installed under frontend/node_modules.
|
|
117
|
+
nodePaths,
|
|
118
|
+
logLevel: "silent",
|
|
119
|
+
});
|
|
120
|
+
const out = result.outputFiles?.[0];
|
|
121
|
+
if (!out) {
|
|
122
|
+
throw new Error(`esbuild produced no output for ${entryPath}`);
|
|
123
|
+
}
|
|
124
|
+
return out.text;
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
"import": "./dist/linter.js",
|
|
19
19
|
"default": "./dist/linter.js"
|
|
20
20
|
},
|
|
21
|
+
"./dev-shims": {
|
|
22
|
+
"import": "./dist/dev-shims.js",
|
|
23
|
+
"default": "./dist/dev-shims.js"
|
|
24
|
+
},
|
|
21
25
|
"./contract": {
|
|
22
26
|
"types": "./dist/index.d.ts",
|
|
23
27
|
"require": "./dist/contract.cjs",
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
],
|
|
36
40
|
"scripts": {
|
|
37
41
|
"build": "node scripts/build.js",
|
|
38
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js"
|
|
42
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-subscription.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js"
|
|
39
43
|
},
|
|
40
44
|
"engines": {
|
|
41
45
|
"node": ">=18"
|
|
@@ -66,6 +70,7 @@
|
|
|
66
70
|
}
|
|
67
71
|
},
|
|
68
72
|
"optionalDependencies": {
|
|
73
|
+
"esbuild": "^0.28.0",
|
|
69
74
|
"sucrase": "^3.35.0"
|
|
70
75
|
},
|
|
71
76
|
"keywords": [
|