@udt/parser-utils 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,64 @@
1
+ import { type PlainObject } from "./isJsonObject.js";
2
+
3
+ /**
4
+ * Extracts specfied properties and their values from
5
+ * an object.
6
+ *
7
+ * A bit like destructuring, except that you can also use
8
+ * regular expressions to match the properties you want to
9
+ * extract.
10
+ *
11
+ * @param object The plain object from which to extract
12
+ * properties.
13
+ * @param propsToExtract An array of names, and/or
14
+ * regular expressions that match the
15
+ * names of the properties to exract.
16
+ * @returns The object containing the extracted properties
17
+ * and their values, and an array of all property
18
+ * names of the input object that were not extracted.
19
+ */
20
+ export function extractProperties(
21
+ object: PlainObject,
22
+ propsToExtract: (string | RegExp)[]
23
+ ): {
24
+ /**
25
+ * Object containg the extract properties
26
+ * and their respective values.
27
+ */
28
+ extracted: PlainObject;
29
+
30
+ /**
31
+ * Array of property names of the input
32
+ * object that were not extracted.
33
+ */
34
+ remainingProps: string[];
35
+ } {
36
+ const propNamesToExtract = propsToExtract.filter(
37
+ (prop) => typeof prop === "string"
38
+ );
39
+ const propRegexesToExtract = propsToExtract.filter(
40
+ (prop) => prop instanceof RegExp
41
+ );
42
+
43
+ const extracted: PlainObject = {};
44
+ const remainingProps: string[] = [];
45
+ Object.getOwnPropertyNames(object).forEach((prop) => {
46
+ if (
47
+ propNamesToExtract.some(
48
+ (propNameToExtract) => propNameToExtract === prop
49
+ ) ||
50
+ propRegexesToExtract.some((propRegexToExtract) =>
51
+ propRegexToExtract.test(prop)
52
+ )
53
+ ) {
54
+ extracted[prop] = object[prop];
55
+ } else {
56
+ remainingProps.push(prop);
57
+ }
58
+ });
59
+
60
+ return {
61
+ extracted,
62
+ remainingProps,
63
+ };
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./parseData.js";
2
+ export * from "./isJsonObject.js";
3
+ export * from "./extractProperties.js";
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { isPlainObject } from "./isJsonObject.js";
3
+
4
+ describe("isPlainObject()", () => {
5
+ it("Returns true for a genuine object", () => {
6
+ expect(isPlainObject({ foo: "bar" })).toBe(true);
7
+ });
8
+
9
+ it("Returns false for null", () => {
10
+ expect(isPlainObject(null)).toBe(false);
11
+ });
12
+
13
+ it("Returns false for an array", () => {
14
+ expect(isPlainObject([1, 2, 3])).toBe(false);
15
+ });
16
+
17
+ it("Returns false for undefined", () => {
18
+ expect(isPlainObject(undefined)).toBe(false);
19
+ });
20
+
21
+ it("Returns false for a function", () => {
22
+ expect(isPlainObject(function () {})).toBe(false);
23
+ });
24
+
25
+ it("Returns false for a boolean", () => {
26
+ expect(isPlainObject(true)).toBe(false);
27
+ });
28
+
29
+ it("Returns false for a number", () => {
30
+ expect(isPlainObject(42)).toBe(false);
31
+ });
32
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A plain object.
3
+ *
4
+ * I.e. `{ ... }`, and not an array or `null`, which
5
+ * JavaScript's `typeof` operator would also return
6
+ * `"object"` for.
7
+ */
8
+ export type PlainObject = Record<string, unknown>;
9
+
10
+ /**
11
+ * Checks whether a value is a plain object.
12
+ *
13
+ * @param value The value to check.
14
+ *
15
+ * @returns `true` if it is a plain object, `false` otherwise.
16
+ */
17
+ export function isPlainObject(value: unknown): value is PlainObject {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
@@ -0,0 +1,438 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ parseData,
4
+ type ParseDesignTokenDataFn,
5
+ type ParseGroupDataFn,
6
+ type IsDesignTokenDataFn,
7
+ type ParserConfig,
8
+ InvalidDataError,
9
+ AddChildFn,
10
+ } from "./parseData.js";
11
+
12
+ interface TestGroup {
13
+ type: "group";
14
+ }
15
+
16
+ interface TestDesignToken {
17
+ type: "token";
18
+ }
19
+
20
+ interface TestParentContext {
21
+ dummyData?: number;
22
+
23
+ // Used to pass in a mock addChild() function for
24
+ // mockParseGroupData() to use for testing
25
+ addChild?: AddChildFn<TestGroup, TestDesignToken>;
26
+ }
27
+
28
+ interface ParseDataCall {
29
+ type: "group" | "token";
30
+ path: string[];
31
+ }
32
+
33
+ interface AddChildCall {
34
+ type: "addChild";
35
+ name: string;
36
+ }
37
+
38
+ // Used to track the order in which parseXyzData and addChild
39
+ // functions are called
40
+ let parseDataCalls: (ParseDataCall | AddChildCall)[] = [];
41
+
42
+ /*
43
+ * For the purpose of these tests, any object with a
44
+ * value property will be considered a design token.
45
+ *
46
+ * Intentionally not using (current) DTCG syntax, to
47
+ * demonstrate that parseData() could be used to parse
48
+ * other formats too, as long as they use the same
49
+ * nested object structure for groups and tokens.
50
+ */
51
+ const mockIsDesignTokenData = vi.fn<IsDesignTokenDataFn>(
52
+ (data) => data.value !== undefined
53
+ );
54
+
55
+ const mockParseGroupData = vi.fn<
56
+ ParseGroupDataFn<TestGroup, TestDesignToken, TestParentContext>
57
+ >((_data, path, contextFromParent) => {
58
+ parseDataCalls.push({
59
+ type: "group",
60
+ path,
61
+ });
62
+ return {
63
+ group: {
64
+ type: "group",
65
+ },
66
+ // pass through contextFromParent
67
+ contextForChildren: contextFromParent,
68
+
69
+ // pass through addChild
70
+ addChild: contextFromParent?.addChild,
71
+ };
72
+ });
73
+
74
+ const mockParseDesignTokenData = vi.fn<
75
+ ParseDesignTokenDataFn<TestDesignToken, TestParentContext>
76
+ >((_data, path, _contextFromParent) => {
77
+ parseDataCalls.push({ type: "token", path });
78
+ const result: TestDesignToken = {
79
+ type: "token",
80
+ };
81
+ return result;
82
+ });
83
+
84
+ describe("parseData()", () => {
85
+ const parserConfig: ParserConfig<
86
+ TestDesignToken,
87
+ TestGroup,
88
+ TestParentContext
89
+ > = {
90
+ isDesignTokenData: mockIsDesignTokenData,
91
+ groupPropsToExtract: [],
92
+ parseGroupData: mockParseGroupData,
93
+ parseDesignTokenData: mockParseDesignTokenData,
94
+ };
95
+
96
+ beforeEach(() => {
97
+ // Reset stuff
98
+ parseDataCalls = [];
99
+ parserConfig.groupPropsToExtract = [];
100
+ mockIsDesignTokenData.mockClear();
101
+ mockParseGroupData.mockClear();
102
+ mockParseDesignTokenData.mockClear();
103
+ });
104
+
105
+ describe("parsing an empty group object", () => {
106
+ let parsedGroupOrToken: TestGroup | TestDesignToken;
107
+
108
+ beforeEach(() => {
109
+ parsedGroupOrToken = parseData({}, parserConfig);
110
+ });
111
+
112
+ it("returns a group", () => {
113
+ expect(parsedGroupOrToken.type).toBe("group");
114
+ });
115
+
116
+ it("calls isDesignTokenData function once", () => {
117
+ expect(mockIsDesignTokenData).toHaveBeenCalledOnce();
118
+ });
119
+
120
+ it("class isDesignTokenData function with input data", () => {
121
+ expect(mockIsDesignTokenData).toHaveBeenCalledWith({});
122
+ });
123
+
124
+ it("calls parseGroupData function once", () => {
125
+ expect(mockParseGroupData).toHaveBeenCalledOnce();
126
+ });
127
+
128
+ it("calls parseGroupData function with empty group and empty path array", () => {
129
+ expect(mockParseGroupData).toHaveBeenCalledWith({}, [], undefined);
130
+ });
131
+
132
+ it("does not call parseDesignTokenData function", () => {
133
+ expect(mockParseDesignTokenData).not.toHaveBeenCalled();
134
+ });
135
+ });
136
+
137
+ describe("parsing a design token object", () => {
138
+ const testTokenData = {
139
+ value: "whatever", // <-- this makes it a token
140
+ other: "thing",
141
+ stuff: 123,
142
+ notAGroup: {},
143
+ };
144
+ let parsedGroupOrToken: TestGroup | TestDesignToken;
145
+
146
+ beforeEach(() => {
147
+ parsedGroupOrToken = parseData(testTokenData, parserConfig);
148
+ });
149
+
150
+ it("returns a design token", () => {
151
+ expect(parsedGroupOrToken.type).toBe("token");
152
+ });
153
+
154
+ it("does not call parseGroupData function", () => {
155
+ expect(mockParseGroupData).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("calls parseDesignTokenData function once", () => {
159
+ expect(mockParseDesignTokenData).toHaveBeenCalledOnce();
160
+ });
161
+
162
+ it("calls parseDesignTokenData function with complete data and empty path array", () => {
163
+ expect(mockParseDesignTokenData).toHaveBeenCalledWith(
164
+ testTokenData,
165
+ [],
166
+ undefined
167
+ );
168
+ });
169
+ });
170
+
171
+ describe("parsing a group object containing design tokens and nested groups", () => {
172
+ /*
173
+ Contains:
174
+
175
+ - 4 groups: <root>, emptyGroup,
176
+ groupWithTokens, nestedGroup
177
+
178
+ - 5 tokens: nestedToken, anotherNestedToken,
179
+ deeplyNestedToken, token, anotherToken
180
+ */
181
+ const testData = {
182
+ specialGroupProp: {
183
+ value: "not a token value",
184
+ description: "This is not a group or token!",
185
+ },
186
+ emptyGroup: {},
187
+ groupWithTokens: {
188
+ nestedToken: {
189
+ value: "a",
190
+ },
191
+ anotherNestedToken: {
192
+ value: "b",
193
+ },
194
+ nestedGroup: {
195
+ deeplyNestedToken: {
196
+ value: "x",
197
+ },
198
+ },
199
+ },
200
+ token: {
201
+ value: 1,
202
+ },
203
+ anotherToken: {
204
+ value: 2,
205
+ },
206
+ };
207
+
208
+ beforeEach(() => {
209
+ parserConfig.groupPropsToExtract = ["specialGroupProp"];
210
+ parseData(testData, parserConfig);
211
+ });
212
+
213
+ it("calls parseDesignTokenData function 5 times", () => {
214
+ expect(mockParseDesignTokenData).toHaveBeenCalledTimes(5);
215
+ });
216
+
217
+ it("calls parseGroupData function 4 times", () => {
218
+ expect(mockParseGroupData).toHaveBeenCalledTimes(4);
219
+ });
220
+
221
+ it("only passed extracted group props to the parseGroupData function", () => {
222
+ expect(mockParseGroupData).toHaveBeenNthCalledWith(
223
+ 1,
224
+ { specialGroupProp: testData.specialGroupProp },
225
+ [],
226
+ undefined
227
+ );
228
+ });
229
+
230
+ it("traverses the data depth-first", () => {
231
+ // Start with root group
232
+ expect(parseDataCalls[0]).toStrictEqual({
233
+ type: "group",
234
+ path: [],
235
+ });
236
+
237
+ // "emptyGroup" next
238
+ expect(parseDataCalls[1]).toStrictEqual({
239
+ type: "group",
240
+ path: ["emptyGroup"],
241
+ });
242
+
243
+ // "groupWithTokens" next
244
+ expect(parseDataCalls[2]).toStrictEqual({
245
+ type: "group",
246
+ path: ["groupWithTokens"],
247
+ });
248
+
249
+ // "nestedToken" next
250
+ expect(parseDataCalls[3]).toStrictEqual({
251
+ type: "token",
252
+ path: ["groupWithTokens", "nestedToken"],
253
+ });
254
+
255
+ // "anotherNestedToken" next
256
+ expect(parseDataCalls[4]).toStrictEqual({
257
+ type: "token",
258
+ path: ["groupWithTokens", "anotherNestedToken"],
259
+ });
260
+
261
+ // "nestedGroup" next
262
+ expect(parseDataCalls[5]).toStrictEqual({
263
+ type: "group",
264
+ path: ["groupWithTokens", "nestedGroup"],
265
+ });
266
+
267
+ // "deeplyNestedToken" next
268
+ expect(parseDataCalls[6]).toStrictEqual({
269
+ type: "token",
270
+ path: ["groupWithTokens", "nestedGroup", "deeplyNestedToken"],
271
+ });
272
+
273
+ // "token" next
274
+ expect(parseDataCalls[7]).toStrictEqual({
275
+ type: "token",
276
+ path: ["token"],
277
+ });
278
+
279
+ // "anotherToken" next
280
+ expect(parseDataCalls[8]).toStrictEqual({
281
+ type: "token",
282
+ path: ["anotherToken"],
283
+ });
284
+ });
285
+ });
286
+
287
+ describe("with context data", () => {
288
+ const testData = { group: {}, token: { value: 123 } };
289
+ const testContext: TestParentContext = { dummyData: 42 };
290
+
291
+ beforeEach(() => {
292
+ parseData(testData, parserConfig, testContext);
293
+ });
294
+
295
+ it("passes the context data to outermost parseGroupData call", () => {
296
+ // 1st call is the root group
297
+ expect(mockParseGroupData).toHaveBeenNthCalledWith(
298
+ 1,
299
+ {},
300
+ [],
301
+ testContext
302
+ );
303
+ });
304
+
305
+ it("passes context from parent group to parseGroupData calls for child groups", () => {
306
+ expect(mockParseGroupData).toHaveBeenNthCalledWith(
307
+ 2,
308
+ {},
309
+ ["group"],
310
+ testContext
311
+ );
312
+ });
313
+
314
+ it("passes context from parent group to parseDesignTokenData calls for child tokens", () => {
315
+ expect(mockParseDesignTokenData).toHaveBeenCalledWith(
316
+ testData.token,
317
+ ["token"],
318
+ testContext
319
+ );
320
+ });
321
+ });
322
+
323
+ describe("using addChild functions", () => {
324
+ const testData = {
325
+ tokenA: { value: 1 },
326
+ tokenB: { value: 2 },
327
+ groupC: { tokenX: { value: 99 }, tokenY: { value: 100 } },
328
+ };
329
+ const mockAddChild = vi.fn<AddChildFn<TestGroup, TestDesignToken>>(
330
+ (name, _child) => {
331
+ parseDataCalls.push({
332
+ type: "addChild",
333
+ name,
334
+ });
335
+ }
336
+ );
337
+ const testContext: TestParentContext = { addChild: mockAddChild };
338
+
339
+ beforeEach(() => {
340
+ mockAddChild.mockClear();
341
+ parseData(testData, parserConfig, testContext);
342
+ });
343
+
344
+ it("calls addChild for every child of every group", () => {
345
+ // Root group contains 3 children: "tokenA", "tokenB" and "groupC"
346
+ // "groupC" contains 2 children: "tokenX" and "tokenY"
347
+ // 3 + 2 = 5
348
+ expect(mockAddChild).toHaveBeenCalledTimes(5);
349
+ });
350
+
351
+ it("adds a nested group to its parent before parsing its children", () => {
352
+ // Steps should be:
353
+ // 0 parse root group
354
+ expect(parseDataCalls[0]).toStrictEqual({ type: "group", path: [] });
355
+
356
+ // 1 parse token "tokenA"
357
+ expect(parseDataCalls[1]).toStrictEqual({
358
+ type: "token",
359
+ path: ["tokenA"],
360
+ });
361
+
362
+ // 2 addChild "tokenA" to root group
363
+ expect(parseDataCalls[2]).toStrictEqual({
364
+ type: "addChild",
365
+ name: "tokenA",
366
+ });
367
+
368
+ // 3 parse token "tokenB"
369
+ expect(parseDataCalls[3]).toStrictEqual({
370
+ type: "token",
371
+ path: ["tokenB"],
372
+ });
373
+
374
+ // 4 addChild "tokenB" to root group
375
+ expect(parseDataCalls[4]).toStrictEqual({
376
+ type: "addChild",
377
+ name: "tokenB",
378
+ });
379
+
380
+ // 5 parse group "groupC"
381
+ expect(parseDataCalls[5]).toStrictEqual({
382
+ type: "group",
383
+ path: ["groupC"],
384
+ });
385
+
386
+ // 6 addChild "groupC" to root group
387
+ // NOTE that this happens *before* any of groupC's
388
+ // children are parsed.
389
+ expect(parseDataCalls[6]).toStrictEqual({
390
+ type: "addChild",
391
+ name: "groupC",
392
+ });
393
+
394
+ // 7 parse token "tokenX"
395
+ expect(parseDataCalls[7]).toStrictEqual({
396
+ type: "token",
397
+ path: ["groupC", "tokenX"],
398
+ });
399
+
400
+ // 8 addChild "tokenX" to "groupC"
401
+ expect(parseDataCalls[8]).toStrictEqual({
402
+ type: "addChild",
403
+ name: "tokenX",
404
+ });
405
+
406
+ // 9 parse token "tokenY"
407
+ expect(parseDataCalls[9]).toStrictEqual({
408
+ type: "token",
409
+ path: ["groupC", "tokenY"],
410
+ });
411
+
412
+ // 10 addChild "tokenY" to "groupC"
413
+ expect(parseDataCalls[10]).toStrictEqual({
414
+ type: "addChild",
415
+ name: "tokenY",
416
+ });
417
+ });
418
+ });
419
+
420
+ it("throws an InvalidDataError when given a non-object input", () => {
421
+ expect(() => parseData(123, parserConfig)).toThrowError(InvalidDataError);
422
+ });
423
+
424
+ it("throws an InvalidDataError when encountering a non-object child", () => {
425
+ expect(() => parseData({ brokenThing: 123 }, parserConfig)).toThrowError(
426
+ InvalidDataError
427
+ );
428
+ });
429
+
430
+ it("includes the path and data when throwing an InvalidDataError", () => {
431
+ try {
432
+ parseData({ brokenThing: 123 }, parserConfig);
433
+ } catch (error) {
434
+ expect((error as InvalidDataError).path).toStrictEqual(["brokenThing"]);
435
+ expect((error as InvalidDataError).data).toBe(123);
436
+ }
437
+ });
438
+ });