@player-tools/dsl 0.0.2-next.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/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@player-tools/dsl",
3
+ "version": "0.0.2-next.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "registry": "https://registry.npmjs.org"
7
+ },
8
+ "peerDependencies": {
9
+ "react": "^17.0.2"
10
+ },
11
+ "dependencies": {
12
+ "@player-ui/types": "^0.2.0",
13
+ "@types/mkdirp": "^1.0.2",
14
+ "@types/signale": "^1.4.2",
15
+ "chalk": "^4.0.1",
16
+ "command-line-application": "^0.10.1",
17
+ "fs-extra": "^10.0.0",
18
+ "globby": "^11.0.1",
19
+ "jsonc-parser": "^2.3.1",
20
+ "mkdirp": "^1.0.4",
21
+ "react-flatten-children": "^1.1.2",
22
+ "react-json-reconciler": "^2.0.0",
23
+ "react-merge-refs": "^1.1.0",
24
+ "source-map-js": "^1.0.2",
25
+ "signale": "^1.4.0",
26
+ "ts-node": "^10.4.0",
27
+ "typescript": "4.4.4",
28
+ "tapable-ts": "^0.1.0",
29
+ "dequal": "^2.0.2",
30
+ "@babel/runtime": "7.15.4"
31
+ },
32
+ "main": "dist/index.cjs.js",
33
+ "module": "dist/index.esm.js",
34
+ "typings": "dist/index.d.ts",
35
+ "sideEffects": false,
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/player-ui/tools"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/player-ui/tools/issues"
43
+ },
44
+ "homepage": "https://player-ui.github.io",
45
+ "contributors": [
46
+ {
47
+ "name": "Ketan Reddy",
48
+ "url": "https://github.com/KetanReddy"
49
+ }
50
+ ]
51
+ }
@@ -0,0 +1,136 @@
1
+ import React from 'react';
2
+ import type { JsonNode } from 'react-json-reconciler';
3
+ import { flattenNodes } from 'react-json-reconciler';
4
+ import { SlotContext } from './components';
5
+ import type { WithChildren } from './types';
6
+
7
+ const IDSuffixContext = React.createContext<string>('root');
8
+
9
+ export const IndexSuffixStopContext = React.createContext<boolean>(false);
10
+
11
+ /** Get the generated id */
12
+ export const useGetIdPrefix = () => {
13
+ return React.useContext(IDSuffixContext);
14
+ };
15
+
16
+ /** Add a suffix to a generated id */
17
+ export const IDSuffixProvider = (
18
+ props: WithChildren<{
19
+ /** The suffix to append */
20
+ suffix: string;
21
+ }>
22
+ ) => {
23
+ const currentPrefix = useGetIdPrefix();
24
+
25
+ return (
26
+ <IDSuffixContext.Provider
27
+ value={[
28
+ currentPrefix === 'root' ? undefined : currentPrefix,
29
+ props.suffix,
30
+ ]
31
+ .filter(Boolean)
32
+ .join('-')}
33
+ >
34
+ {props.children}
35
+ </IDSuffixContext.Provider>
36
+ );
37
+ };
38
+
39
+ /** Override the generated id with the supplied one */
40
+ export const IDProvider = (
41
+ props: WithChildren<{
42
+ /** The new id to use */
43
+ id?: string;
44
+ }>
45
+ ) => {
46
+ if (props.id) {
47
+ return (
48
+ <IDSuffixContext.Provider value={props.id}>
49
+ {props.children}
50
+ </IDSuffixContext.Provider>
51
+ );
52
+ }
53
+
54
+ // eslint-disable-next-line react/jsx-no-useless-fragment
55
+ return <>{props.children}</>;
56
+ };
57
+
58
+ /** Get the index of an item in a slot */
59
+ export const useIndexInSlot = (ref: React.RefObject<JsonNode>) => {
60
+ const [index, setIndex] = React.useState(-1);
61
+ const slotContext = React.useContext(SlotContext);
62
+
63
+ React.useEffect(() => {
64
+ if (!slotContext?.isArray) {
65
+ throw new Error('Cannot get index in non-array slot');
66
+ }
67
+
68
+ if (ref.current && slotContext?.ref.current?.valueNode?.type === 'array') {
69
+ const allChildren = flattenNodes(
70
+ slotContext.ref.current.valueNode.children
71
+ );
72
+ const foundIndex = allChildren.indexOf(ref.current);
73
+
74
+ if (foundIndex !== index) {
75
+ setIndex(foundIndex);
76
+ }
77
+ }
78
+ }, [index, ref, slotContext?.isArray, slotContext?.ref]);
79
+
80
+ return index;
81
+ };
82
+
83
+ /** Add the index to the id path when in an array slot */
84
+ export const IDSuffixIndexProvider = (
85
+ props: WithChildren<{
86
+ /** The ref to use */
87
+ wrapperRef: React.RefObject<JsonNode>;
88
+
89
+ /** if the suffix is in a template, the id to use */
90
+ templateIndex?: string;
91
+ }>
92
+ ) => {
93
+ const slotIndex = useIndexInSlot(props.wrapperRef);
94
+
95
+ const stopIndex = React.useContext(IndexSuffixStopContext);
96
+
97
+ if (stopIndex) {
98
+ // eslint-disable-next-line react/jsx-no-useless-fragment
99
+ return <>{props.children}</>;
100
+ }
101
+
102
+ return (
103
+ <IDSuffixProvider suffix={props.templateIndex ?? String(slotIndex)}>
104
+ <IndexSuffixStopContext.Provider value>
105
+ {props.children}
106
+ </IndexSuffixStopContext.Provider>
107
+ </IDSuffixProvider>
108
+ );
109
+ };
110
+
111
+ /** Wrap a slot with the index if in an array slot */
112
+ export const OptionalIDSuffixProvider = (
113
+ props: WithChildren<{
114
+ /** The ref to walk upwards and use as an index */
115
+ wrapperRef: React.RefObject<JsonNode>;
116
+
117
+ /** if the suffix is in a template, the id to use */
118
+ templateIndex?: string;
119
+ }>
120
+ ) => {
121
+ const slotContext = React.useContext(SlotContext);
122
+
123
+ if (slotContext?.isArray) {
124
+ return (
125
+ <IDSuffixIndexProvider
126
+ wrapperRef={props.wrapperRef}
127
+ templateIndex={props.templateIndex}
128
+ >
129
+ {props.children}
130
+ </IDSuffixIndexProvider>
131
+ );
132
+ }
133
+
134
+ // eslint-disable-next-line react/jsx-no-useless-fragment
135
+ return <>{props.children}</>;
136
+ };
@@ -0,0 +1,219 @@
1
+ import React from 'react';
2
+ import type { JsonType } from 'react-json-reconciler';
3
+ import { SourceMapGenerator, SourceMapConsumer } from 'source-map-js';
4
+ import { render } from 'react-json-reconciler';
5
+ import type { Flow, View, Navigation as PlayerNav } from '@player-ui/types';
6
+ import { SyncHook } from 'tapable-ts';
7
+ import type { SerializeType } from './types';
8
+ import type { Navigation } from '../types';
9
+ import { SchemaGenerator } from './schema';
10
+
11
+ /** Recursively find BindingTemplateInstance and call toValue on them */
12
+ const parseNavigationExpressions = (nav: Navigation): PlayerNav => {
13
+ /** Same as above but signature changed */
14
+ function replaceExpWithStr(obj: any): any {
15
+ /** call toValue if BindingTemplateInstance otherwise continue */
16
+ function convExp(value: any): any {
17
+ return value && typeof value === 'object' && value.__type === 'expression'
18
+ ? value.toValue() // exp, onStart, and onEnd don't need to be wrapped in @[]@
19
+ : replaceExpWithStr(value);
20
+ }
21
+
22
+ if (Array.isArray(obj)) {
23
+ return obj.map(convExp);
24
+ }
25
+
26
+ if (typeof obj === 'object') {
27
+ return Object.fromEntries(
28
+ Object.entries(obj).map(([key, value]) => [key, convExp(value)])
29
+ );
30
+ }
31
+
32
+ return obj;
33
+ }
34
+
35
+ return replaceExpWithStr(nav);
36
+ };
37
+
38
+ type SourceMapList = Array<{
39
+ /** The mappings of the original */
40
+ sourceMap: string;
41
+ /**
42
+ * The id of the view we're indexing off of
43
+ * This should be a unique global identifier within the generated code
44
+ * e.g. `"id": "view_0",`
45
+ */
46
+ offsetIndexSearch: string;
47
+ /** The generated source that produced the map */
48
+ source: string;
49
+ }>;
50
+
51
+ /** Given a list of source maps for all generated views, merge them into 1 */
52
+ const mergeSourceMaps = (
53
+ sourceMaps: SourceMapList,
54
+ generated: string
55
+ ): string => {
56
+ const generator = new SourceMapGenerator();
57
+ sourceMaps.forEach(({ sourceMap, offsetIndexSearch, source }) => {
58
+ const generatedLineOffset = generated
59
+ .split('\n')
60
+ .findIndex((line) => line.includes(offsetIndexSearch));
61
+
62
+ const sourceLineOffset = source
63
+ .split('\n')
64
+ .findIndex((line) => line.includes(offsetIndexSearch));
65
+
66
+ const lineOffset = generatedLineOffset - sourceLineOffset;
67
+
68
+ const generatedLine = generated.split('\n')[generatedLineOffset];
69
+ const sourceLine = source.split('\n')[sourceLineOffset];
70
+
71
+ const generatedColumn = generatedLine.indexOf(offsetIndexSearch);
72
+ const sourceColumn = sourceLine.indexOf(offsetIndexSearch);
73
+ const columnOffset = generatedColumn - sourceColumn;
74
+
75
+ const consumer = new SourceMapConsumer(JSON.parse(sourceMap));
76
+ consumer.eachMapping((mapping) => {
77
+ generator.addMapping({
78
+ generated: {
79
+ line: mapping.generatedLine + lineOffset,
80
+ column: mapping.generatedColumn + columnOffset,
81
+ },
82
+ original: {
83
+ line: mapping.originalLine,
84
+ column: mapping.originalColumn,
85
+ },
86
+ source: mapping.source,
87
+ });
88
+ });
89
+ });
90
+
91
+ return generator.toString();
92
+ };
93
+
94
+ /** A compiler for transforming DSL content into JSON */
95
+ export class DSLCompiler {
96
+ public hooks = {
97
+ schemaGenerator: new SyncHook<[SchemaGenerator]>(),
98
+ };
99
+
100
+ /** Convert an object (flow, view, schema, etc) into it's JSON representation */
101
+ async serialize(value: unknown): Promise<{
102
+ /** the JSON value of the source */
103
+ value: JsonType | undefined;
104
+
105
+ /** the fingerprinted content type of the source */
106
+ contentType: SerializeType;
107
+
108
+ /** The sourcemap of the content */
109
+ sourceMap?: string;
110
+ }> {
111
+ if (typeof value !== 'object' || value === null) {
112
+ throw new Error('Unable to serialize non-object');
113
+ }
114
+
115
+ if (React.isValidElement(value)) {
116
+ const { jsonValue, sourceMap } = await render(value, {
117
+ collectSourceMap: true,
118
+ });
119
+
120
+ return {
121
+ value: jsonValue,
122
+ sourceMap,
123
+ contentType: 'view',
124
+ };
125
+ }
126
+
127
+ if ('navigation' in value) {
128
+ // Source maps from all the nested views
129
+ // Merge these together before returning
130
+ const allSourceMaps: SourceMapList = [];
131
+
132
+ // Assume this is a flow
133
+ const copiedValue: Flow = {
134
+ ...(value as any),
135
+ };
136
+
137
+ copiedValue.views = (await Promise.all(
138
+ copiedValue?.views?.map(async (node: any) => {
139
+ const { jsonValue, sourceMap, stringValue } = await render(node, {
140
+ collectSourceMap: true,
141
+ });
142
+
143
+ if (sourceMap) {
144
+ // Find the line that is the id of the view
145
+ // Use that as the identifier for the sourcemap offset calc
146
+ const searchIdLine = stringValue
147
+ .split('\n')
148
+ .find((line) =>
149
+ line.includes(
150
+ `"id": "${(jsonValue as Record<string, string>).id}"`
151
+ )
152
+ );
153
+
154
+ if (searchIdLine) {
155
+ allSourceMaps.push({
156
+ sourceMap,
157
+ offsetIndexSearch: searchIdLine,
158
+ source: stringValue,
159
+ });
160
+ }
161
+ }
162
+
163
+ return jsonValue;
164
+ }) ?? []
165
+ )) as View[];
166
+
167
+ // Go through the flow and sub out any view refs that are react elements w/ the right id
168
+ if ('navigation' in value) {
169
+ Object.entries((value as Flow).navigation).forEach(([navKey, node]) => {
170
+ if (typeof node === 'object') {
171
+ Object.entries(node).forEach(([nodeKey, flowNode]) => {
172
+ if (
173
+ flowNode &&
174
+ typeof flowNode === 'object' &&
175
+ 'state_type' in flowNode &&
176
+ flowNode.state_type === 'VIEW' &&
177
+ React.isValidElement(flowNode.ref)
178
+ ) {
179
+ const actualViewIndex = (value as Flow).views?.indexOf?.(
180
+ flowNode.ref as any
181
+ );
182
+
183
+ if (actualViewIndex !== undefined && actualViewIndex > -1) {
184
+ const actualId = copiedValue.views?.[actualViewIndex]?.id;
185
+
186
+ (copiedValue as any).navigation[navKey][nodeKey].ref =
187
+ actualId;
188
+ }
189
+ }
190
+ });
191
+ }
192
+ });
193
+
194
+ copiedValue.navigation = parseNavigationExpressions(
195
+ copiedValue.navigation
196
+ );
197
+ }
198
+
199
+ if (value) {
200
+ return {
201
+ value: copiedValue as JsonType,
202
+ contentType: 'flow',
203
+ sourceMap: mergeSourceMaps(
204
+ allSourceMaps,
205
+ JSON.stringify(copiedValue, null, 2)
206
+ ),
207
+ };
208
+ }
209
+ }
210
+
211
+ const schemaGenerator = new SchemaGenerator();
212
+ this.hooks.schemaGenerator.call(schemaGenerator);
213
+
214
+ return {
215
+ value: schemaGenerator.toSchema(value) as JsonType,
216
+ contentType: 'schema',
217
+ };
218
+ }
219
+ }
@@ -0,0 +1,2 @@
1
+ export * from './compiler';
2
+ export * from './types';
@@ -0,0 +1,250 @@
1
+ import type { Schema, Language } from '@player-ui/types';
2
+ import signale from 'signale';
3
+ import { dequal } from 'dequal';
4
+ import { SyncWaterfallHook } from 'tapable-ts';
5
+ import { binding as b } from '..';
6
+ import type { BindingTemplateInstance } from '../string-templates';
7
+
8
+ const bindingSymbol = Symbol('binding');
9
+
10
+ export const SchemaTypeName = Symbol('Schema Rename');
11
+
12
+ interface SchemaChildren {
13
+ /** Object property that will be used to create the intermediate type */
14
+ name: string;
15
+
16
+ /** Object properties children that will be parsed */
17
+ child: object;
18
+ }
19
+
20
+ type SchemaNode = (Schema.DataType | Language.DataTypeRef) & {
21
+ /** Overwrite the name of the generated type */
22
+ [SchemaTypeName]?: string;
23
+ };
24
+
25
+ /**
26
+ * Type Guard for the `Schema.DataType` and `Language.DataTypeRef` type
27
+ * A bit hacky but since `Schema.Schema` must have a `Schema.DataType` as
28
+ * the final product we have to call it that even if it is a `Language.DataTypeRef`
29
+ */
30
+ const isTypeDef = (property: SchemaNode): property is Schema.DataType => {
31
+ return (property as Schema.DataType).type !== undefined;
32
+ };
33
+
34
+ /**
35
+ * Generator for `Schema.Schema` Objects
36
+ */
37
+ export class SchemaGenerator {
38
+ private children: Array<SchemaChildren>;
39
+ private generatedDataTypeNames: Map<string, SchemaNode>;
40
+
41
+ public hooks = {
42
+ createSchemaNode: new SyncWaterfallHook<
43
+ [
44
+ node: Schema.DataType,
45
+ originalProperty: Record<string | symbol, unknown>
46
+ ]
47
+ >(),
48
+ };
49
+
50
+ constructor() {
51
+ this.children = [];
52
+ this.generatedDataTypeNames = new Map();
53
+ }
54
+
55
+ /**
56
+ * Converts an object to a `Schema.Schema` representation
57
+ * Note: uses iteration to prevent potentially very deep recursion on large objects
58
+ */
59
+ public toSchema = (schema: any): Schema.Schema => {
60
+ const newSchema: Schema.Schema = {
61
+ ROOT: {},
62
+ };
63
+
64
+ this.children = [];
65
+ this.generatedDataTypeNames.clear();
66
+
67
+ Object.keys(schema).forEach((property) => {
68
+ const subType = schema[property] as SchemaNode;
69
+ newSchema.ROOT[property] = this.hooks.createSchemaNode.call(
70
+ this.processChildren(property, subType),
71
+ subType as any
72
+ );
73
+ });
74
+
75
+ while (this.children.length > 0) {
76
+ const c = this.children.pop();
77
+ if (c === undefined) {
78
+ break;
79
+ }
80
+
81
+ const { name, child } = c;
82
+ const typeDef = {} as any;
83
+
84
+ Object.keys(child).forEach((property) => {
85
+ const subType = (child as any)[property] as SchemaNode;
86
+ typeDef[property] = this.hooks.createSchemaNode.call(
87
+ this.processChildren(property, subType),
88
+ subType as any
89
+ );
90
+ });
91
+ newSchema[name] = typeDef;
92
+ }
93
+
94
+ return newSchema;
95
+ };
96
+
97
+ /**
98
+ * Processes the children of an object Node
99
+ * Newly discovered children get added to the provided array
100
+ */
101
+ private processChildren(
102
+ property: string,
103
+ subType: SchemaNode
104
+ ): Schema.DataType {
105
+ if (isTypeDef(subType)) {
106
+ return subType;
107
+ }
108
+
109
+ let intermediateType;
110
+
111
+ if (Array.isArray(subType)) {
112
+ if (subType.length > 1) {
113
+ signale.warn(
114
+ `Type ${property} has multiple types in array, should only contain one top level object type. Only taking first defined type`
115
+ );
116
+ }
117
+
118
+ const subTypeName = subType[0][SchemaTypeName] ?? property;
119
+ intermediateType = this.makePlaceholderArrayType(subTypeName);
120
+ this.children.push({ name: intermediateType.type, child: subType[0] });
121
+ } else {
122
+ const subTypeName = subType[SchemaTypeName] ?? property;
123
+ intermediateType = this.makePlaceholderType(subTypeName);
124
+ this.children.push({ name: intermediateType.type, child: subType });
125
+ }
126
+
127
+ if (this.generatedDataTypeNames.has(intermediateType.type)) {
128
+ if (
129
+ !dequal(
130
+ subType,
131
+ this.generatedDataTypeNames.get(intermediateType.type) as object
132
+ )
133
+ ) {
134
+ throw new Error(
135
+ `Error: Generated two intermediate types with the name: ${intermediateType.type} that are of different shapes`
136
+ );
137
+ }
138
+
139
+ // remove last added type since we don't need to reprocess it
140
+ this.children.pop();
141
+ }
142
+
143
+ this.generatedDataTypeNames.set(intermediateType.type, subType);
144
+ return intermediateType;
145
+ }
146
+
147
+ /**
148
+ * Make an intermediate `Schema.DataType` object given a name
149
+ */
150
+ private makePlaceholderType = (typeName: string): Schema.DataType => {
151
+ return {
152
+ type: `${typeName}Type`,
153
+ };
154
+ };
155
+
156
+ /**
157
+ * Make an intermediate `Schema.DataType` object with array support given a name
158
+ */
159
+ private makePlaceholderArrayType(typeName: string): Schema.DataType {
160
+ return {
161
+ type: `${typeName}Type`,
162
+ isArray: true,
163
+ };
164
+ }
165
+ }
166
+
167
+ export type MakeArrayIntoIndexRef<T extends any[]> = {
168
+ [key: number]: MakeBindingRefable<T[0]>;
169
+ /** Used when referencing bindings from within a template */
170
+ _index_: MakeBindingRefable<T[0]>;
171
+ } & BindingTemplateInstance;
172
+
173
+ export type MakeBindingRefable<T> = {
174
+ [P in keyof T]: T[P] extends any[]
175
+ ? MakeArrayIntoIndexRef<T[P]>
176
+ : MakeBindingRefable<T[P]>;
177
+ } & BindingTemplateInstance;
178
+
179
+ /**
180
+ * Adds bindings to an object so that the object can be directly used in JSX
181
+ */
182
+ export function makeBindingsForObject<Type>(
183
+ obj: Type,
184
+ arrayAccessorKeys = ['_index_']
185
+ ): MakeBindingRefable<Type> {
186
+ /** Proxy to track binding callbacks */
187
+ const accessor = (paths: string[]) => {
188
+ const bindingMap = new WeakMap<any, BindingTemplateInstance>();
189
+
190
+ return {
191
+ ownKeys(target: any) {
192
+ return Reflect.ownKeys(target);
193
+ },
194
+
195
+ get(target: any, key: any): any {
196
+ const bindingKeys = Object.keys(target);
197
+
198
+ if (!bindingMap.has(target)) {
199
+ bindingMap.set(target, b`${paths.join('.')}`);
200
+ }
201
+
202
+ if (key === bindingSymbol) {
203
+ return paths;
204
+ }
205
+
206
+ if (
207
+ Array.isArray(target) &&
208
+ (arrayAccessorKeys.includes(key) || typeof key === 'number')
209
+ ) {
210
+ return new Proxy(target[0], accessor(paths.concat([key])));
211
+ }
212
+
213
+ if (bindingKeys.includes(key) && typeof target[key] === 'object') {
214
+ return new Proxy(target[key], accessor(paths.concat([key])));
215
+ }
216
+
217
+ const createdInstance = bindingMap.get(target) as any;
218
+ return createdInstance?.[key];
219
+ },
220
+ };
221
+ };
222
+
223
+ return new Proxy(obj, accessor([])) as MakeBindingRefable<Type>;
224
+ }
225
+
226
+ /**
227
+ * Generates binding for an object property
228
+ */
229
+ export const getBindingFromObject = (obj: any) => {
230
+ const baseBindings = obj[bindingSymbol] as string[];
231
+ if (!Array.isArray(baseBindings) || baseBindings.length === 0) {
232
+ throw new Error(`Unable to get binding for ${obj}`);
233
+ }
234
+
235
+ return b`${baseBindings.join('.')}`;
236
+ };
237
+
238
+ /**
239
+ * Returns the binding string from an object path
240
+ */
241
+ export const getBindingStringFromObject = (obj: any) => {
242
+ return getBindingFromObject(obj).toString();
243
+ };
244
+
245
+ /**
246
+ * Returns the ref string from an object path
247
+ */
248
+ export const getRefStringFromObject = (obj: any) => {
249
+ return getBindingFromObject(obj).toRefString();
250
+ };
@@ -0,0 +1,18 @@
1
+ import type { Schema, Navigation, Flow } from '@player-ui/types';
2
+ import type { RemoveUnknownIndex, AddUnknownIndex } from '../types';
3
+
4
+ export type FlowWithoutUnknown = RemoveUnknownIndex<Flow>;
5
+ export type FlowWithReactViews = AddUnknownIndex<
6
+ Omit<FlowWithoutUnknown, 'views'> & {
7
+ /** An array of JSX view elements */
8
+ views?: Array<React.ReactElement>;
9
+ }
10
+ >;
11
+
12
+ export type SerializeType = 'view' | 'flow' | 'schema' | 'navigation';
13
+
14
+ export type SerializablePlayerExportTypes =
15
+ | React.ReactElement
16
+ | FlowWithReactViews
17
+ | Schema.Schema
18
+ | Navigation;