- Custom client changes

This commit is contained in:
Ferdi Koomen 2020-12-17 16:24:49 +01:00
parent 4bd109e772
commit 56f9289ff1
31 changed files with 186 additions and 79 deletions

View File

@ -12,7 +12,7 @@ program
.version(pkg.version)
.requiredOption('-i, --input <value>', 'OpenAPI specification, can be a path, url or string content (required)')
.requiredOption('-o, --output <value>', 'Output directory (required)')
.option('-c, --client <value>', 'HTTP client to generate [fetch, xhr, node]', 'fetch')
.option('-c, --client <value>', 'HTTP client to generate [fetch, xhr, node] or path to custom request file', 'fetch')
.option('--useOptions', 'Use options instead of arguments')
.option('--useUnionTypes', 'Use union types instead of enums')
.option('--exportCore <value>', 'Write core files to disk', true)

View File

@ -80,6 +80,7 @@
"@types/js-yaml": "3.12.5",
"@types/node": "14.14.13",
"@types/node-fetch": "2.5.7",
"@types/qs": "6.9.4",
"@typescript-eslint/eslint-plugin": "4.9.1",
"@typescript-eslint/parser": "4.9.1",
"codecov": "3.8.1",
@ -90,11 +91,13 @@
"express": "4.17.1",
"form-data": "3.0.0",
"glob": "7.1.6",
"httpntlm": "1.7.6",
"jest": "26.6.3",
"jest-cli": "26.6.3",
"node-fetch": "2.6.1",
"prettier": "2.2.1",
"puppeteer": "5.5.0",
"qs": "6.9.4",
"rollup": "2.34.2",
"rollup-plugin-terser": "7.0.2",
"rollup-plugin-typescript2": "0.29.0",

5
src/HttpClient.ts Normal file
View File

@ -0,0 +1,5 @@
export enum HttpClient {
FETCH = 'fetch',
XHR = 'xhr',
NODE = 'node',
}

View File

@ -1,3 +1,4 @@
import { HttpClient } from './HttpClient';
import { parse as parseV2 } from './openApi/v2';
import { parse as parseV3 } from './openApi/v3';
import { getOpenApiSpec } from './utils/getOpenApiSpec';
@ -7,16 +8,12 @@ import { postProcessClient } from './utils/postProcessClient';
import { registerHandlebarTemplates } from './utils/registerHandlebarTemplates';
import { writeClient } from './utils/writeClient';
export enum HttpClient {
FETCH = 'fetch',
XHR = 'xhr',
NODE = 'node',
}
export { HttpClient } from './HttpClient';
export type Options = {
input: string | Record<string, any>;
output: string;
httpClient?: HttpClient;
httpClient?: string | HttpClient;
useOptions?: boolean;
useUnionTypes?: boolean;
exportCore?: boolean;

View File

@ -1,10 +1,10 @@
import * as os from 'os';
import { EOL } from 'os';
import { getComment } from './getComment';
describe('getComment', () => {
it('should parse comments', () => {
const multiline = 'Testing multiline comments.' + os.EOL + ' * This must go to the next line.' + os.EOL + ' * ' + os.EOL + ' * This will contain a break.';
const multiline = 'Testing multiline comments.' + EOL + ' * This must go to the next line.' + EOL + ' * ' + EOL + ' * This will contain a break.';
expect(getComment('')).toEqual(null);
expect(getComment('Hello')).toEqual('Hello');
expect(getComment('Hello World!')).toEqual('Hello World!');

View File

@ -1,4 +1,4 @@
import * as os from 'os';
import { EOL } from 'os';
/**
* Cleanup comment and prefix multiline comments with "*",
@ -7,7 +7,7 @@ import * as os from 'os';
*/
export function getComment(comment?: string): string | null {
if (comment) {
return comment.replace(/\r?\n(.*)/g, (_, w) => `${os.EOL} * ${w.trim()}`);
return comment.replace(/\r?\n(.*)/g, (_, w) => `${EOL} * ${w.trim()}`);
}
return null;
}

View File

@ -1,10 +1,10 @@
import * as os from 'os';
import { EOL } from 'os';
import { getComment } from './getComment';
describe('getComment', () => {
it('should parse comments', () => {
const multiline = 'Testing multiline comments.' + os.EOL + ' * This must go to the next line.' + os.EOL + ' * ' + os.EOL + ' * This will contain a break.';
const multiline = 'Testing multiline comments.' + EOL + ' * This must go to the next line.' + EOL + ' * ' + EOL + ' * This will contain a break.';
expect(getComment('')).toEqual(null);
expect(getComment('Hello')).toEqual('Hello');
expect(getComment('Hello World!')).toEqual('Hello World!');

View File

@ -1,4 +1,4 @@
import * as os from 'os';
import { EOL } from 'os';
/**
* Cleanup comment and prefix multiline comments with "*",
@ -7,7 +7,7 @@ import * as os from 'os';
*/
export function getComment(comment?: string): string | null {
if (comment) {
return comment.replace(/\r?\n(.*)/g, (_, w) => `${os.EOL} * ${w.trim()}`);
return comment.replace(/\r?\n(.*)/g, (_, w) => `${EOL} * ${w.trim()}`);
}
return null;
}

View File

@ -4,8 +4,5 @@ async function sendRequest(options: ApiRequestOptions, url: string): Promise<Res
headers: await getHeaders(options),
body: getRequestBody(options),
};
if (OpenAPI.WITH_CREDENTIALS) {
request.credentials = 'include';
}
return await fetch(url, request);
}

View File

@ -1,8 +1,8 @@
import * as os from 'os';
import { EOL } from 'os';
export function format(s: string): string {
let indent: number = 0;
let lines = s.split(os.EOL);
let lines = s.split(EOL);
lines = lines.map(line => {
line = line.trim().replace(/^\*/g, ' *');
let i = indent;
@ -19,5 +19,5 @@ export function format(s: string): string {
}
return result;
});
return lines.join(os.EOL);
return lines.join(EOL);
}

View File

@ -1,5 +1,5 @@
import * as yaml from 'js-yaml';
import * as path from 'path';
import { safeLoad } from 'js-yaml';
import { extname } from 'path';
import { readSpec } from './readSpec';
@ -10,13 +10,13 @@ import { readSpec } from './readSpec';
* @param input
*/
export async function getOpenApiSpec(input: string): Promise<any> {
const extension = path.extname(input).toLowerCase();
const extension = extname(input).toLowerCase();
const content = await readSpec(input);
switch (extension) {
case '.yml':
case '.yaml':
try {
return yaml.safeLoad(content);
return safeLoad(content);
} catch (e) {
throw new Error(`Could not parse OpenApi YAML: "${input}"`);
}

View File

@ -1,15 +1,15 @@
import * as path from 'path';
import { resolve } from 'path';
import { isSubDirectory } from './isSubdirectory';
describe('isSubDirectory', () => {
it('should return correct result', () => {
expect(isSubDirectory(path.resolve('/'), path.resolve('/'))).toBeFalsy();
expect(isSubDirectory(path.resolve('.'), path.resolve('.'))).toBeFalsy();
expect(isSubDirectory(path.resolve('./project'), path.resolve('./project'))).toBeFalsy();
expect(isSubDirectory(path.resolve('./project'), path.resolve('../'))).toBeFalsy();
expect(isSubDirectory(path.resolve('./project'), path.resolve('../../'))).toBeFalsy();
expect(isSubDirectory(path.resolve('./'), path.resolve('./output'))).toBeTruthy();
expect(isSubDirectory(path.resolve('./'), path.resolve('../output'))).toBeTruthy();
expect(isSubDirectory(resolve('/'), resolve('/'))).toBeFalsy();
expect(isSubDirectory(resolve('.'), resolve('.'))).toBeFalsy();
expect(isSubDirectory(resolve('./project'), resolve('./project'))).toBeFalsy();
expect(isSubDirectory(resolve('./project'), resolve('../'))).toBeFalsy();
expect(isSubDirectory(resolve('./project'), resolve('../../'))).toBeFalsy();
expect(isSubDirectory(resolve('./'), resolve('./output'))).toBeTruthy();
expect(isSubDirectory(resolve('./'), resolve('../output'))).toBeTruthy();
});
});

View File

@ -1,5 +1,5 @@
import * as path from 'path';
import { relative } from 'path';
export function isSubDirectory(parent: string, child: string) {
return path.relative(child, parent).startsWith('..');
return relative(child, parent).startsWith('..');
}

View File

@ -1,4 +1,4 @@
import * as path from 'path';
import { resolve } from 'path';
import { exists, readFile } from './fileSystem';
@ -7,7 +7,7 @@ import { exists, readFile } from './fileSystem';
* @param input
*/
export async function readSpecFromDisk(input: string): Promise<string> {
const filePath = path.resolve(process.cwd(), input);
const filePath = resolve(process.cwd(), input);
const fileExists = await exists(filePath);
if (fileExists) {
try {

View File

@ -1,4 +1,4 @@
import * as http from 'http';
import { get } from 'http';
/**
* Download the spec file from a HTTP resource
@ -6,7 +6,7 @@ import * as http from 'http';
*/
export async function readSpecFromHttp(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
http.get(url, response => {
get(url, response => {
let body = '';
response.on('data', chunk => {
body += chunk;

View File

@ -1,4 +1,4 @@
import * as https from 'https';
import { get } from 'https';
/**
* Download the spec file from a HTTPS resource
@ -6,7 +6,7 @@ import * as https from 'https';
*/
export async function readSpecFromHttps(url: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
https.get(url, response => {
get(url, response => {
let body = '';
response.on('data', chunk => {
body += chunk;

View File

@ -1,5 +1,5 @@
import type { Client } from '../client/interfaces/Client';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { mkdir, rmdir, writeFile } from './fileSystem';
import { Templates } from './registerHandlebarTemplates';
import { writeClient } from './writeClient';

View File

@ -1,7 +1,7 @@
import * as path from 'path';
import { resolve } from 'path';
import type { Client } from '../client/interfaces/Client';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { mkdir, rmdir } from './fileSystem';
import { isSubDirectory } from './isSubdirectory';
import { Templates } from './registerHandlebarTemplates';
@ -12,7 +12,7 @@ import { writeClientSchemas } from './writeClientSchemas';
import { writeClientServices } from './writeClientServices';
/**
* Write our OpenAPI client, using the given templates at the given output path.
* Write our OpenAPI client, using the given templates at the given output
* @param client Client object with all the models, services, etc.
* @param templates Templates wrapper with all loaded Handlebars templates
* @param output The relative location of the output directory
@ -28,7 +28,7 @@ export async function writeClient(
client: Client,
templates: Templates,
output: string,
httpClient: HttpClient,
httpClient: string | HttpClient,
useOptions: boolean,
useUnionTypes: boolean,
exportCore: boolean,
@ -36,11 +36,11 @@ export async function writeClient(
exportModels: boolean,
exportSchemas: boolean
): Promise<void> {
const outputPath = path.resolve(process.cwd(), output);
const outputPathCore = path.resolve(outputPath, 'core');
const outputPathModels = path.resolve(outputPath, 'models');
const outputPathSchemas = path.resolve(outputPath, 'schemas');
const outputPathServices = path.resolve(outputPath, 'services');
const outputPath = resolve(process.cwd(), output);
const outputPathCore = resolve(outputPath, 'core');
const outputPathModels = resolve(outputPath, 'models');
const outputPathSchemas = resolve(outputPath, 'schemas');
const outputPathServices = resolve(outputPath, 'services');
if (!isSubDirectory(process.cwd(), output)) {
throw new Error(`Output folder is not a subdirectory of the current working directory`);

View File

@ -1,5 +1,5 @@
import type { Client } from '../client/interfaces/Client';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { Templates } from './registerHandlebarTemplates';
import { writeClientCore } from './writeClientCore';

View File

@ -1,8 +1,8 @@
import * as path from 'path';
import { resolve } from 'path';
import type { Client } from '../client/interfaces/Client';
import { HttpClient } from '../index';
import { writeFile } from './fileSystem';
import { HttpClient } from '../HttpClient';
import { copyFile, exists, writeFile } from './fileSystem';
import { Templates } from './registerHandlebarTemplates';
/**
@ -12,15 +12,31 @@ import { Templates } from './registerHandlebarTemplates';
* @param outputPath Directory to write the generated files to
* @param httpClient The selected httpClient (fetch, xhr or node)
*/
export async function writeClientCore(client: Client, templates: Templates, outputPath: string, httpClient: HttpClient): Promise<void> {
export async function writeClientCore(client: Client, templates: Templates, outputPath: string, httpClient: string | HttpClient): Promise<void> {
const context = {
httpClient,
server: client.server,
version: client.version,
};
await writeFile(path.resolve(outputPath, 'OpenAPI.ts'), templates.core.settings(context));
await writeFile(path.resolve(outputPath, 'ApiError.ts'), templates.core.apiError({}));
await writeFile(path.resolve(outputPath, 'ApiRequestOptions.ts'), templates.core.apiRequestOptions({}));
await writeFile(path.resolve(outputPath, 'ApiResult.ts'), templates.core.apiResult({}));
await writeFile(path.resolve(outputPath, 'request.ts'), templates.core.request(context));
await writeFile(resolve(outputPath, 'OpenAPI.ts'), templates.core.settings(context));
await writeFile(resolve(outputPath, 'ApiError.ts'), templates.core.apiError({}));
await writeFile(resolve(outputPath, 'ApiRequestOptions.ts'), templates.core.apiRequestOptions({}));
await writeFile(resolve(outputPath, 'ApiResult.ts'), templates.core.apiResult({}));
switch (httpClient) {
case HttpClient.FETCH:
case HttpClient.XHR:
case HttpClient.NODE:
await writeFile(resolve(outputPath, 'request.ts'), templates.core.request(context));
break;
default:
const customRequestFile = resolve(process.cwd(), httpClient);
const customRequestFileExists = await exists(customRequestFile);
if (!customRequestFileExists) {
throw new Error(`Custom request file "${customRequestFile}" does not exists`);
}
await copyFile(customRequestFile, resolve(outputPath, 'request.ts'));
break;
}
}

View File

@ -1,4 +1,4 @@
import * as path from 'path';
import { resolve } from 'path';
import type { Client } from '../client/interfaces/Client';
import { writeFile } from './fileSystem';
@ -30,7 +30,7 @@ export async function writeClientIndex(
exportSchemas: boolean
): Promise<void> {
await writeFile(
path.resolve(outputPath, 'index.ts'),
resolve(outputPath, 'index.ts'),
templates.index({
exportCore,
exportServices,

View File

@ -1,5 +1,5 @@
import type { Model } from '../client/interfaces/Model';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { Templates } from './registerHandlebarTemplates';
import { writeClientModels } from './writeClientModels';

View File

@ -1,7 +1,7 @@
import * as path from 'path';
import { resolve } from 'path';
import type { Model } from '../client/interfaces/Model';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { format } from './format';
import { Templates } from './registerHandlebarTemplates';
@ -14,9 +14,9 @@ import { Templates } from './registerHandlebarTemplates';
* @param httpClient The selected httpClient (fetch, xhr or node)
* @param useUnionTypes Use union types instead of enums
*/
export async function writeClientModels(models: Model[], templates: Templates, outputPath: string, httpClient: HttpClient, useUnionTypes: boolean): Promise<void> {
export async function writeClientModels(models: Model[], templates: Templates, outputPath: string, httpClient: string | HttpClient, useUnionTypes: boolean): Promise<void> {
for (const model of models) {
const file = path.resolve(outputPath, `${model.name}.ts`);
const file = resolve(outputPath, `${model.name}.ts`);
const templateResult = templates.exports.model({
...model,
httpClient,

View File

@ -1,5 +1,5 @@
import type { Model } from '../client/interfaces/Model';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { Templates } from './registerHandlebarTemplates';
import { writeClientSchemas } from './writeClientSchemas';

View File

@ -1,7 +1,7 @@
import * as path from 'path';
import { resolve } from 'path';
import type { Model } from '../client/interfaces/Model';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { format } from './format';
import { Templates } from './registerHandlebarTemplates';
@ -14,9 +14,9 @@ import { Templates } from './registerHandlebarTemplates';
* @param httpClient The selected httpClient (fetch, xhr or node)
* @param useUnionTypes Use union types instead of enums
*/
export async function writeClientSchemas(models: Model[], templates: Templates, outputPath: string, httpClient: HttpClient, useUnionTypes: boolean): Promise<void> {
export async function writeClientSchemas(models: Model[], templates: Templates, outputPath: string, httpClient: string | HttpClient, useUnionTypes: boolean): Promise<void> {
for (const model of models) {
const file = path.resolve(outputPath, `$${model.name}.ts`);
const file = resolve(outputPath, `$${model.name}.ts`);
const templateResult = templates.exports.schema({
...model,
httpClient,

View File

@ -1,5 +1,5 @@
import type { Service } from '../client/interfaces/Service';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { Templates } from './registerHandlebarTemplates';
import { writeClientServices } from './writeClientServices';

View File

@ -1,7 +1,7 @@
import * as path from 'path';
import { resolve } from 'path';
import type { Service } from '../client/interfaces/Service';
import { HttpClient } from '../index';
import { HttpClient } from '../HttpClient';
import { writeFile } from './fileSystem';
import { format } from './format';
import { Templates } from './registerHandlebarTemplates';
@ -17,9 +17,9 @@ const VERSION_TEMPLATE_STRING = 'OpenAPI.VERSION';
* @param useUnionTypes Use union types instead of enums
* @param useOptions Use options or arguments functions
*/
export async function writeClientServices(services: Service[], templates: Templates, outputPath: string, httpClient: HttpClient, useUnionTypes: boolean, useOptions: boolean): Promise<void> {
export async function writeClientServices(services: Service[], templates: Templates, outputPath: string, httpClient: string | HttpClient, useUnionTypes: boolean, useOptions: boolean): Promise<void> {
for (const service of services) {
const file = path.resolve(outputPath, `${service.name}.ts`);
const file = resolve(outputPath, `${service.name}.ts`);
const useVersion = service.operations.some(operation => operation.path.includes(VERSION_TEMPLATE_STRING));
const templateResult = templates.exports.service({
...service,

60
test/custom/request.ts Normal file
View File

@ -0,0 +1,60 @@
// @ts-ignore
import httpntlm from 'httpntlm';
import { promisify } from 'util';
import { stringify } from 'qs';
type TRequestOptions = {
readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH';
readonly path: string;
readonly cookies?: Record<string, any>;
readonly headers?: Record<string, any>;
readonly query?: Record<string, any>;
readonly formData?: Record<string, any>;
readonly body?: any;
readonly responseHeader?: string;
readonly errors?: Record<number, string>;
}
type TResult = {
readonly url: string;
readonly ok: boolean;
readonly status: number;
readonly statusText: string;
readonly body: any;
}
export async function request(options: TRequestOptions): Promise<TResult> {
const path = options.path.replace(/[:]/g, '_');
const query = stringify(options.query);
const host = 'http://localhost:8080';
const url = `${host}${path}${query}`;
const body = options.body && JSON.stringify(options.body);
const headers = {
...options.headers,
'Accept': 'application/json',
'Content-Type': 'application/json; charset=utf-8',
'Accept-Encoding': 'identity',
}
const method = options.method.toLowerCase();
const fetch = promisify(httpntlm[method]);
const response = await fetch({
url,
domain: 'domain',
username: 'username',
password: 'password',
headers,
body,
});
return {
url,
ok: response.ok,
status: response.statusCode,
statusText: response.statusText,
body: JSON.parse(response.body),
};
}

View File

@ -16,6 +16,7 @@
"strictNullChecks": true,
"strictFunctionTypes": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"skipLibCheck": true
},

2
types/index.d.ts vendored
View File

@ -7,7 +7,7 @@ export declare enum HttpClient {
export type Options = {
input: string | Record<string, any>;
output: string;
httpClient?: HttpClient;
httpClient?: string | HttpClient;
useOptions?: boolean;
useUnionTypes?: boolean;
exportCore?: boolean;

View File

@ -1358,6 +1358,11 @@
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.5.tgz#434711bdd49eb5ee69d90c1d67c354a9a8ecb18b"
integrity sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==
"@types/qs@6.9.4":
version "6.9.4"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a"
integrity sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ==
"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
@ -3169,6 +3174,19 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
httpntlm@1.7.6:
version "1.7.6"
resolved "https://registry.yarnpkg.com/httpntlm/-/httpntlm-1.7.6.tgz#6991e8352836007d67101b83db8ed0f915f906d0"
integrity sha1-aZHoNSg2AH1nEBuD247Q+RX5BtA=
dependencies:
httpreq ">=0.4.22"
underscore "~1.7.0"
httpreq@>=0.4.22:
version "0.4.24"
resolved "https://registry.yarnpkg.com/httpreq/-/httpreq-0.4.24.tgz#4335ffd82cd969668a39465c929ac61d6393627f"
integrity sha1-QzX/2CzZaWaKOUZckprGHWOTYn8=
https-proxy-agent@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz#702b71fb5520a132a66de1f67541d9e62154d82b"
@ -4697,6 +4715,11 @@ qs@6.7.0:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
qs@6.9.4:
version "6.9.4"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.4.tgz#9090b290d1f91728d3c22e54843ca44aea5ab687"
integrity sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==
qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
@ -5701,6 +5724,11 @@ unbzip2-stream@^1.3.3:
buffer "^5.2.1"
through "^2.3.8"
underscore@~1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.7.0.tgz#6bbaf0877500d36be34ecaa584e0db9fef035209"
integrity sha1-a7rwh3UA02vjTsqlhODbn+8DUgk=
unicode-canonical-property-names-ecmascript@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"