├── .changeset ├── README.md └── config.json ├── .circleci └── config.yml ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── releases │ └── yarn-1.22.0.js ├── .yarnrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── commitlint.config.js ├── index.ts ├── jest.config.js ├── package.json ├── scripts ├── generate.ts ├── utils.test.ts └── utils.ts ├── tsconfig.esm.json ├── tsconfig.json └── yarn.lock /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by 4 | `@changesets/cli`, a build tool that works with multi-package repos, or 5 | single-package repos to help you version and publish your code. You can find the 6 | full documentation for it 7 | [in our repository](https://github.com/changesets/changesets) 8 | 9 | We have a quick list of common questions to get you started engaging with this 10 | project in 11 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@0.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "linked": [], 6 | "access": "public", 7 | "baseBranch": "main" 8 | } 9 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | node: circleci/node@1.1.6 4 | jobs: 5 | build: 6 | executor: node/default 7 | steps: 8 | - checkout 9 | - node/install: 10 | install-npm: false 11 | install-yarn: false 12 | node-version: '12.6.0' 13 | - node/with-cache: 14 | cache-key: yarn.lock 15 | steps: 16 | - run: yarn install --frozen-lockfile 17 | - run: yarn build 18 | - run: yarn test 19 | - run: yarn format-check 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repo 12 | uses: actions/checkout@main 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@main 17 | with: 18 | node-version: '12.6.0' 19 | - name: Install Dependencies 20 | run: yarn 21 | - name: Create Release Pull Request or Publish to npm 22 | uses: changesets/action@master 23 | with: 24 | publish: yarn release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | dist 5 | *-types.ts -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.15.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | dist 3 | .yarn 4 | package.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 80, 6 | "proseWrap": "always" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true 4 | }, 5 | "editor.formatOnSave": true, 6 | "eslint.enable": false, 7 | "typescript.validate.enable": true, 8 | "typescript.tsdk": "node_modules/typescript/lib" 9 | } 10 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | lastUpdateCheck 1582211859969 6 | save-prefix "" 7 | yarn-path ".yarn/releases/yarn-1.22.0.js" 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @sketch-hq/sketch-file-format-ts 2 | 3 | ## 5.2.2 4 | 5 | ### Patch Changes 6 | 7 | - 84c9fc2: Update sketch-file-format to 3.7.1 8 | 9 | ## 5.2.1 10 | 11 | ### Patch Changes 12 | 13 | - 3a2a6ea: Update sketch-file-format to 3.7.1 14 | 15 | ## 5.2.0 16 | 17 | ### Minor Changes 18 | 19 | - 8b269e9: Update sketch-file-format to 3.7.0 20 | 21 | ### Patch Changes 22 | 23 | - 14b05c3: Update to sketch-file-format@3.6.6. 24 | 25 | ## 5.1.1 26 | 27 | ### Patch Changes 28 | 29 | - 5b298d9: Update to file format 3.6.2 30 | 31 | ## 5.1.0 32 | 33 | ### Minor Changes 34 | 35 | - 1aff125: Include ESM entrypoint. 36 | 37 | ## 5.0.0 38 | 39 | ### Major Changes 40 | 41 | - 8c997ce: Update to Sketch File Format `3.6.1` 42 | 43 | ### Minor Changes 44 | 45 | - 8c997ce: Add `ClassMap` a type that maps `_class` strings to their object 46 | type. 47 | 48 | ## 4.0.5 49 | 50 | ### Patch Changes 51 | 52 | - c0292ca: Fix problem where package was being built as ESM. 53 | 54 | ## 4.0.4 55 | 56 | ### Patch Changes 57 | 58 | - 208e91f: Update dependencies. 59 | - 208e91f: Add a `ClassValue` enum containing values for all possible `_class` 60 | values. 61 | 62 | ## 4.0.3 63 | 64 | ### Patch Changes 65 | 66 | - 4d07d47: Update to file format `3.5.3` 67 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sketch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sketch File Format TS 2 | 3 | > ⚠️ **This repository has been merged into the [`sketch-document`](https://github.com/sketch-hq/sketch-document) monorepo which contains both JSON Schema and TypeScript types. The latest npm package is published to [`@sketch-hq/sketch-file-format-ts`](https://www.npmjs.com/package/@sketch-hq/sketch-file-format-ts)** 4 | 5 | TypeScript types for the Sketch File Format 6 | 7 | ## Overview 8 | 9 | This repo contains TypeScript types automatically generated from the 10 | [Sketch File Format](https://github.com/sketch-hq/sketch-file-format) JSON 11 | Schemas. 12 | 13 | Types are maintained and exported for each Sketch File Format major version. See 14 | usage instructions below for more information. 15 | 16 | ## Use cases 17 | 18 | - Strongly type objects representing Sketch documents, or fragments of Sketch 19 | documents in TypeScript projects 20 | 21 | ## Related projects 22 | 23 | - [sketch-file-format](https://github.com/sketch-hq/sketch-file-format) 24 | 25 | ## Usage 26 | 27 | Add the npm module using `npm` or `yarn` 28 | 29 | ```sh 30 | npm install @sketch-hq/sketch-file-format-ts 31 | ``` 32 | 33 | Types for the latest file format are on the default export 34 | 35 | ```typescript 36 | import FileFormat from '@sketch-hq/sketch-file-format-ts' 37 | ``` 38 | 39 | Types for historical file formats are accessible via named exports 40 | 41 | ```typescript 42 | import { FileFormat1, FileFormat2 } from '@sketch-hq/sketch-file-format-ts' 43 | ``` 44 | 45 | > Read about how file format versions map to Sketch document versions 46 | > [here](https://github.com/sketch-hq/sketch-file-format) 47 | 48 | ## Examples 49 | 50 | Create a typed layer blur object 51 | 52 | ```typescript 53 | import FileFormat from '@sketch-hq/sketch-file-format-ts' 54 | 55 | const blur: FileFormat.Blur = { 56 | _class: 'blur', 57 | isEnabled: false, 58 | center: '{0.5, 0.5}', 59 | motionAngle: 0, 60 | radius: 10, 61 | saturation: 1, 62 | type: FileFormat.BlurType.Gaussian, 63 | } 64 | ``` 65 | 66 | Layer types can be narrowed using discriminate properties on the helper union 67 | types like `AnyLayer` 68 | 69 | ```typescript 70 | import FileFormat from '@sketch-hq/sketch-file-format-ts' 71 | 72 | const mapLayers = (layers: FileFormat.AnyLayer[]) => { 73 | return layers.map((layer) => { 74 | switch (layer._class) { 75 | case 'bitmap': 76 | // type narrowed to Bitmap layers 77 | case 'star': 78 | // type narrowed to Star layers 79 | } 80 | }) 81 | } 82 | ``` 83 | 84 | Work with representations of Sketch files that could have a range of document 85 | versions 86 | 87 | ```typescript 88 | import { 89 | FileFormat1, 90 | FileFormat2, 91 | FileFormat3, 92 | } from '@sketch-hq/sketch-file-format-ts' 93 | 94 | const processDocumentContents = ( 95 | contents: FileFormat1.Contents | FileFormat2.Contents | FileFormat3.Contents, 96 | ) => { 97 | if (contents.meta.version === 119) { 98 | // type narrowed to file format v1, i.e. Sketch documents with version 119 99 | } 100 | } 101 | ``` 102 | 103 | ## Development 104 | 105 | This section of the readme is related to developing the file format spec. If you 106 | just want to consume the schemas you can safely ignore this. 107 | 108 | ### Approach 109 | 110 | The `scripts/generate.ts` ingests the file format JSON Schema, and generates 111 | type definitions using the TypeScript compiler API. 112 | 113 | We depend on multiple major versions of the schemas in package.json using 114 | [yarn aliases](https://classic.yarnpkg.com/en/docs/cli/add/#toc-yarn-add-alias), 115 | and generate types for each one. This means that users that have to implement 116 | multiple versions of the file format don't need to manually manage multiple 117 | versions of this package. 118 | 119 | ### Scripts 120 | 121 | | Script | Description | 122 | | ----------------- | ----------------------------------------- | 123 | | yarn build | Builds the project into the `dist` folder | 124 | | yarn test | Build script unit tests | 125 | | yarn format-check | Checks the repo with Prettier | 126 | 127 | ### Workflows 128 | 129 | #### Conventional commits 130 | 131 | Try and use the 132 | [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) 133 | convention when writing commit messages. 134 | 135 | #### Changing how the types are generated 136 | 137 | 1. Update `scripts/generate.ts` 138 | 1. Unit test your changes 139 | 1. Determine the semver bump type and call yarn changeset to create an intent to 140 | release your changes (read more about changesets 141 | [here](https://github.com/atlassian/changesets)). 142 | 1. Open a PR to `main` 143 | 144 | #### Adding or updating a file format version 145 | 146 | 1. Use the yarn aliases syntax to add new schema version 147 | 1. Use exact semvers, for example to update or add v3 of the schemas as `3.4.3` 148 | run,
`yarn add @sketch-hq/sketch-file-format-3@npm:@sketch-hq/sketch-file-format@3.4.3` 149 | 1. If the schema version is new to the repo you'll also need to update the 150 | `index.ts` to export the types, and `scripts/generate.ts` to generate the new 151 | types 152 | 1. Open a PR to `main` 153 | 154 | #### Release 155 | 156 | 1. Merge the release PR maintained by the changesets 157 | [GitHub Action](https://github.com/changesets/action). 158 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import * as FileFormat1 from './v1-types.js' 2 | import * as FileFormat2 from './v2-types.js' 3 | import * as FileFormat3 from './v3-types.js' 4 | 5 | export { FileFormat1, FileFormat2, FileFormat3 } 6 | 7 | export default FileFormat3 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testRegex: '^.*\\.test.ts$', 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sketch-hq/sketch-file-format-ts", 3 | "description": "TypeScript types for the Sketch File Format", 4 | "version": "5.2.2", 5 | "main": "dist/cjs/index", 6 | "types": "dist/cjs/index", 7 | "module": "dist/esm/index", 8 | "license": "MIT", 9 | "files": [ 10 | "dist" 11 | ], 12 | "repository": "github:sketch-hq/sketch-file-format-ts", 13 | "keywords": [ 14 | "sketch", 15 | "sketch files", 16 | "file format", 17 | "file spec", 18 | "typescript", 19 | "types" 20 | ], 21 | "devDependencies": { 22 | "@changesets/cli": "2.12.0", 23 | "@sketch-hq/sketch-file-format-1": "npm:@sketch-hq/sketch-file-format@1.1.7", 24 | "@sketch-hq/sketch-file-format-2": "npm:@sketch-hq/sketch-file-format@2.0.3", 25 | "@sketch-hq/sketch-file-format-3": "npm:@sketch-hq/sketch-file-format@3.7.2", 26 | "@types/humps": "2.0.0", 27 | "@types/jest": "26.0.16", 28 | "@types/node": "14.14.10", 29 | "@types/prettier": "2.1.5", 30 | "humps": "2.0.1", 31 | "jest": "26.6.2", 32 | "prettier": "2.2.1", 33 | "ts-jest": "26.4.4", 34 | "ts-node": "9.1.0", 35 | "typescript": "3.8.3" 36 | }, 37 | "scripts": { 38 | "generate": "ts-node ./scripts/generate.ts", 39 | "build": "rm -rf dist && yarn generate && tsc --project tsconfig.json && tsc --project tsconfig.esm.json", 40 | "test": "jest", 41 | "release": "yarn build && changeset publish", 42 | "format-check": "prettier --check {**/,}*.{ts,md,json}" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /scripts/generate.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript' 2 | import { writeFileSync } from 'fs' 3 | import { execSync } from 'child_process' 4 | // @ts-ignore 5 | import schemasv1 from '@sketch-hq/sketch-file-format-1' 6 | // @ts-ignore 7 | import schemasv2 from '@sketch-hq/sketch-file-format-2' 8 | import schemasv3 from '@sketch-hq/sketch-file-format-3' 9 | import { JSONSchema7 } from 'json-schema' 10 | import { 11 | schemaToTopLevelDeclaration, 12 | isLayerSchema, 13 | isGroupSchema, 14 | isObjectSchema, 15 | } from './utils' 16 | 17 | type SchemaMap = { 18 | [key: string]: JSONSchema7 19 | } 20 | 21 | const generate = (version: string, schemas: any) => { 22 | const outFile = `${version}-types.ts` 23 | const definitions: SchemaMap = { 24 | ...((schemas.document.definitions as SchemaMap) || {}), 25 | ...((schemas.fileFormat.definitions as SchemaMap) || {}), 26 | ...((schemas.meta.definitions as SchemaMap) || {}), 27 | ...((schemas.user.definitions as SchemaMap) || {}), 28 | } 29 | 30 | const contents: JSONSchema7 = { 31 | ...schemas.fileFormat, 32 | $id: '#Contents', 33 | } 34 | 35 | const document: JSONSchema7 = { 36 | ...schemas.document, 37 | $id: '#Document', 38 | } 39 | 40 | const anyLayer: JSONSchema7 = { 41 | description: 'Union of all layers', 42 | $id: '#AnyLayer', 43 | oneOf: Object.keys(definitions) 44 | .map((key) => definitions[key]) 45 | .filter((schema) => isLayerSchema(schema)) 46 | .map((schema) => ({ 47 | $ref: schema.$id, 48 | })), 49 | } 50 | 51 | const anyGroup: JSONSchema7 = { 52 | description: 'Union of all group layers', 53 | $id: '#AnyGroup', 54 | oneOf: Object.keys(definitions) 55 | .map((key) => definitions[key]) 56 | .filter((schema) => isGroupSchema(schema)) 57 | .map((schema) => ({ 58 | $ref: schema.$id, 59 | })), 60 | } 61 | 62 | const anyObject: JSONSchema7 = { 63 | description: 'Union of all objects, i.e. objects with a _class property', 64 | $id: '#AnyObject', 65 | oneOf: Object.keys(definitions) 66 | .map((key) => definitions[key]) 67 | .filter((schema) => isObjectSchema(schema)) 68 | .map((schema) => ({ 69 | $ref: schema.$id, 70 | })), 71 | } 72 | 73 | const allClasses: string[] = [ 74 | ...new Set( 75 | Object.keys(definitions) 76 | .map((key) => definitions[key]) 77 | .map((schema) => { 78 | const klass = schema?.properties?._class 79 | return typeof klass === 'object' && 'const' in klass 80 | ? (klass.const as string) 81 | : '' 82 | }) 83 | .filter(Boolean) 84 | .sort(), 85 | ), 86 | ] 87 | 88 | const classValues: JSONSchema7 = { 89 | description: 'Enum of all possible _class property values', 90 | $id: '#ClassValue', 91 | type: 'string', 92 | enum: allClasses, 93 | // @ts-ignore 94 | enumDescriptions: allClasses, 95 | } 96 | 97 | const classMap: JSONSchema7 = { 98 | description: 'A mapping of class values to object types', 99 | $id: '#ClassMap', 100 | type: 'object', 101 | additionalProperties: false, 102 | required: allClasses, 103 | properties: allClasses.reduce((acc, curr) => { 104 | const schema = Object.keys(definitions) 105 | .map((key) => definitions[key]) 106 | .find((schema) => { 107 | const klass = schema?.properties?._class 108 | return ( 109 | typeof klass === 'object' && 110 | 'const' in klass && 111 | (klass.const as string) === curr 112 | ) 113 | }) 114 | return { 115 | [curr]: (schema && { $ref: schema.$id }) || {}, 116 | ...acc, 117 | } 118 | }, {}), 119 | } 120 | 121 | const allDefinitions: SchemaMap = { 122 | ...definitions, 123 | Contents: contents, 124 | Document: document, 125 | AnyLayer: anyLayer, 126 | AnyGroup: anyGroup, 127 | AnyObject: anyObject, 128 | ClassValue: classValues, 129 | ClassMap: classMap, 130 | } 131 | 132 | const types: ts.DeclarationStatement[] = Object.keys( 133 | allDefinitions, 134 | ).map((key) => schemaToTopLevelDeclaration(allDefinitions[key])) 135 | 136 | writeFileSync( 137 | outFile, 138 | ts 139 | .createPrinter() 140 | .printList( 141 | ts.ListFormat.MultiLine, 142 | ts.createNodeArray(types), 143 | ts.createSourceFile( 144 | outFile, 145 | '', 146 | ts.ScriptTarget.Latest, 147 | false, 148 | ts.ScriptKind.TS, 149 | ), 150 | ), 151 | ) 152 | 153 | execSync(`yarn prettier --write ${outFile}`) 154 | } 155 | 156 | generate('v1', schemasv1) 157 | generate('v2', schemasv2) 158 | generate('v3', schemasv3) 159 | -------------------------------------------------------------------------------- /scripts/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaToTypeNode, schemaToTopLevelDeclaration } from './utils' 2 | import * as ts from 'typescript' 3 | import { JSONSchema7 } from 'json-schema' 4 | import { format } from 'prettier' 5 | 6 | /** 7 | * Test helper to visualise/print a statement for snapshotting. 8 | */ 9 | const printStatement = (statement: ts.Statement) => { 10 | const printer = ts.createPrinter() 11 | return format( 12 | printer.printNode( 13 | ts.EmitHint.Unspecified, 14 | statement, 15 | ts.createSourceFile( 16 | '', 17 | '', 18 | ts.ScriptTarget.Latest, 19 | false, 20 | ts.ScriptKind.TS, 21 | ), 22 | ), 23 | { 24 | parser: 'typescript', 25 | semi: false, 26 | singleQuote: true, 27 | }, 28 | ).replace(/[\r\n]+$/, '') 29 | } 30 | 31 | /** 32 | * Test helper to visualise/print a type node for snapshotting. 33 | */ 34 | const printTypeNode = (node: ts.TypeNode) => { 35 | const statementWrapper = ts.createTypeAliasDeclaration( 36 | undefined, 37 | undefined, 38 | ts.createIdentifier('TestType'), 39 | undefined, 40 | node, 41 | ) 42 | return printStatement(statementWrapper) 43 | } 44 | 45 | describe('schemaToTypeNode', () => { 46 | test('Handles string', () => { 47 | const schema: JSONSchema7 = { type: 'string' } 48 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 49 | `"type TestType = string"`, 50 | ) 51 | }) 52 | 53 | test('Handles string enum', () => { 54 | const schema: JSONSchema7 = { type: 'string', enum: ['foo', 'bar'] } 55 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 56 | `"type TestType = 'foo' | 'bar'"`, 57 | ) 58 | }) 59 | 60 | test('Handles number', () => { 61 | const schema: JSONSchema7 = { type: 'number' } 62 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 63 | `"type TestType = number"`, 64 | ) 65 | }) 66 | 67 | test('Handles number enum', () => { 68 | const schema: JSONSchema7 = { type: 'number', enum: [1, 2] } 69 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 70 | `"type TestType = 1 | 2"`, 71 | ) 72 | }) 73 | 74 | test('Handles integers', () => { 75 | const schema: JSONSchema7 = { type: 'integer' } 76 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 77 | `"type TestType = number"`, 78 | ) 79 | }) 80 | 81 | test('Handles integer enum', () => { 82 | const schema: JSONSchema7 = { type: 'integer', enum: [1, 2] } 83 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 84 | `"type TestType = 1 | 2"`, 85 | ) 86 | }) 87 | 88 | test('Handles boolean', () => { 89 | const schema: JSONSchema7 = { type: 'boolean' } 90 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 91 | `"type TestType = boolean"`, 92 | ) 93 | }) 94 | 95 | test('Handles null', () => { 96 | const schema: JSONSchema7 = { type: 'null' } 97 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 98 | `"type TestType = null"`, 99 | ) 100 | }) 101 | 102 | test('Handles boolean enum', () => { 103 | const schema: JSONSchema7 = { type: 'boolean', enum: [true, false] } 104 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 105 | `"type TestType = true | false"`, 106 | ) 107 | }) 108 | 109 | test('Handles empty object', () => { 110 | const schema: JSONSchema7 = {} 111 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 112 | `"type TestType = {}"`, 113 | ) 114 | }) 115 | 116 | test('Handles object', () => { 117 | const schema: JSONSchema7 = { 118 | type: 'object', 119 | properties: { foo: { type: 'string' }, bar: { type: 'number' } }, 120 | } 121 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot(` 122 | "type TestType = { 123 | foo?: string 124 | bar?: number 125 | [key: string]: any 126 | }" 127 | `) 128 | }) 129 | 130 | test('Handles nested objects', () => { 131 | const schema: JSONSchema7 = { 132 | type: 'object', 133 | properties: { 134 | foo: { 135 | type: 'object', 136 | properties: { bar: { type: 'string' }, baz: { type: 'number' } }, 137 | }, 138 | }, 139 | } 140 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot(` 141 | "type TestType = { 142 | foo?: { 143 | bar?: string 144 | baz?: number 145 | [key: string]: any 146 | } 147 | [key: string]: any 148 | }" 149 | `) 150 | }) 151 | 152 | test('Handles required object properties', () => { 153 | const schema: JSONSchema7 = { 154 | type: 'object', 155 | properties: { foo: { type: 'string' }, bar: { type: 'number' } }, 156 | required: ['foo', 'bar'], 157 | } 158 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot(` 159 | "type TestType = { 160 | foo: string 161 | bar: number 162 | [key: string]: any 163 | }" 164 | `) 165 | }) 166 | 167 | test('Handles objects that do not allow additional properties', () => { 168 | const schema: JSONSchema7 = { 169 | type: 'object', 170 | properties: { foo: { type: 'string' }, bar: { type: 'number' } }, 171 | additionalProperties: false, 172 | } 173 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot(` 174 | "type TestType = { 175 | foo?: string 176 | bar?: number 177 | }" 178 | `) 179 | }) 180 | 181 | test('Handles object patternProperties', () => { 182 | const schema: JSONSchema7 = { 183 | type: 'object', 184 | patternProperties: { 185 | foo: { 186 | type: 'string', 187 | }, 188 | bar: { 189 | $ref: '#Bar', 190 | }, 191 | }, 192 | } 193 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot(` 194 | "type TestType = { 195 | [key: string]: string | Bar 196 | }" 197 | `) 198 | }) 199 | 200 | test('Handles simple arrays', () => { 201 | const schema: JSONSchema7 = { type: 'array' } 202 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 203 | `"type TestType = []"`, 204 | ) 205 | }) 206 | 207 | test('Handles typed arrays', () => { 208 | const schema: JSONSchema7 = { type: 'array', items: { type: 'string' } } 209 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 210 | `"type TestType = string[]"`, 211 | ) 212 | }) 213 | 214 | test('Handles string constants', () => { 215 | const schema: JSONSchema7 = { const: 'foobar' } 216 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 217 | `"type TestType = 'foobar'"`, 218 | ) 219 | }) 220 | 221 | test('Handles number constants', () => { 222 | const schema: JSONSchema7 = { const: 1 } 223 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 224 | `"type TestType = 1"`, 225 | ) 226 | }) 227 | 228 | test('Handles refs', () => { 229 | const schema: JSONSchema7 = { $ref: '#Artboard' } 230 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 231 | `"type TestType = Artboard"`, 232 | ) 233 | }) 234 | 235 | test('Handles arrays of refs', () => { 236 | const schema: JSONSchema7 = { type: 'array', items: { $ref: '#Artboard' } } 237 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 238 | `"type TestType = Artboard[]"`, 239 | ) 240 | }) 241 | 242 | test('Handles oneOf', () => { 243 | const schema: JSONSchema7 = { 244 | oneOf: [{ type: 'string' }, { type: 'number' }], 245 | } 246 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 247 | `"type TestType = string | number"`, 248 | ) 249 | }) 250 | 251 | test('Handles refs in oneOf', () => { 252 | const schema: JSONSchema7 = { 253 | oneOf: [{ $ref: '#Artboard' }, { $ref: '#Group' }], 254 | } 255 | expect(printTypeNode(schemaToTypeNode(schema))).toMatchInlineSnapshot( 256 | `"type TestType = Artboard | Group"`, 257 | ) 258 | }) 259 | }) 260 | 261 | describe('schemaToTopLevelDeclaration', () => { 262 | test('Handles top level object definitions', () => { 263 | const schema: JSONSchema7 = { 264 | $id: '#FooBar', 265 | description: 'A foobar', 266 | type: 'object', 267 | properties: { foo: { type: 'string' }, bar: { type: 'string' } }, 268 | } 269 | expect(printStatement(schemaToTopLevelDeclaration(schema))) 270 | .toMatchInlineSnapshot(` 271 | "/** 272 | * A foobar 273 | */ 274 | export type FooBar = { 275 | foo?: string 276 | bar?: string 277 | [key: string]: any 278 | }" 279 | `) 280 | }) 281 | 282 | test('Handles top level enum definitions', () => { 283 | const schema: JSONSchema7 = { 284 | $id: '#MyEnum', 285 | description: 'My enum', 286 | type: 'integer', 287 | enum: [0, 1, 2], 288 | // @ts-ignore 289 | enumDescriptions: ['Zero', 'One', 'Two'], 290 | } 291 | expect(printStatement(schemaToTopLevelDeclaration(schema))) 292 | .toMatchInlineSnapshot(` 293 | "/** 294 | * My enum 295 | */ 296 | export enum MyEnum { 297 | Zero = 0, 298 | One = 1, 299 | Two = 2, 300 | }" 301 | `) 302 | }) 303 | }) 304 | -------------------------------------------------------------------------------- /scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript' 2 | import { JSONSchema7 } from 'json-schema' 3 | import { pascalize } from 'humps' 4 | 5 | /** 6 | * Recursively transforms JSON Schema to TypeScript AST as a generic type node, 7 | * e.g. `string`, `number` or a type literal like `{ foo: string }`. This is not 8 | * a generalised algorithm, and is only tested to work with the Sketch file 9 | * format schemas. 10 | */ 11 | export const schemaToTypeNode = (schema: JSONSchema7): ts.TypeNode => { 12 | switch (schema.type) { 13 | case 'string': 14 | if (schema.enum) { 15 | return ts.createUnionTypeNode( 16 | schema.enum.map((value) => 17 | ts.createLiteralTypeNode(ts.createLiteral(value as string)), 18 | ), 19 | ) 20 | } else { 21 | return ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) 22 | } 23 | case 'number': 24 | case 'integer': 25 | if (schema.enum) { 26 | return ts.createUnionTypeNode( 27 | schema.enum.map((value) => 28 | ts.createLiteralTypeNode(ts.createLiteral(value as number)), 29 | ), 30 | ) 31 | } else { 32 | return ts.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword) 33 | } 34 | case 'boolean': 35 | if (schema.enum) { 36 | return ts.createUnionTypeNode( 37 | schema.enum.map((value) => 38 | ts.createLiteralTypeNode(ts.createLiteral(value as boolean)), 39 | ), 40 | ) 41 | } else { 42 | return ts.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword) 43 | } 44 | case 'null': 45 | return ts.createKeywordTypeNode(ts.SyntaxKind.NullKeyword) 46 | case 'object': 47 | if (typeof schema.properties === 'object') { 48 | const required = schema.required || [] 49 | const additionalProps = 50 | typeof schema.additionalProperties === 'undefined' || 51 | !!schema.additionalProperties 52 | const elements: ts.TypeElement[] = Object.keys( 53 | schema.properties, 54 | ).map((key) => 55 | ts.createPropertySignature( 56 | undefined, 57 | key.includes('-') 58 | ? ts.createStringLiteral(key) 59 | : ts.createIdentifier(key), 60 | required.includes(key) 61 | ? undefined 62 | : ts.createToken(ts.SyntaxKind.QuestionToken), 63 | schemaToTypeNode(schema.properties![key] as JSONSchema7), 64 | undefined, 65 | ), 66 | ) 67 | if (additionalProps) { 68 | elements.push( 69 | ts.createIndexSignature( 70 | undefined, 71 | undefined, 72 | [ 73 | ts.createParameter( 74 | undefined, 75 | undefined, 76 | undefined, 77 | ts.createIdentifier('key'), 78 | undefined, 79 | ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 80 | ), 81 | ], 82 | ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), 83 | ), 84 | ) 85 | } 86 | return ts.createTypeLiteralNode(elements) 87 | } else if (typeof schema.patternProperties === 'object') { 88 | return ts.createTypeLiteralNode([ 89 | ts.createIndexSignature( 90 | undefined, 91 | undefined, 92 | [ 93 | ts.createParameter( 94 | undefined, 95 | undefined, 96 | undefined, 97 | ts.createIdentifier('key'), 98 | undefined, 99 | ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), 100 | ), 101 | ], 102 | ts.createUnionTypeNode( 103 | Object.keys(schema.patternProperties).map((key) => 104 | schemaToTypeNode(schema.patternProperties![key] as JSONSchema7), 105 | ), 106 | ), 107 | ), 108 | ]) 109 | } else { 110 | return ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword) 111 | } 112 | case 'array': 113 | if (typeof schema.items === 'object' && !Array.isArray(schema.items)) { 114 | return ts.createArrayTypeNode(schemaToTypeNode(schema.items)) 115 | } else { 116 | return ts.createTupleTypeNode([]) 117 | } 118 | default: 119 | if (schema.const) { 120 | return ts.createLiteralTypeNode( 121 | ts.createLiteral(schema.const as string), 122 | ) 123 | } else if (schema.$ref) { 124 | return ts.createTypeReferenceNode( 125 | ts.createIdentifier( 126 | schema.$ref.replace(/#/, '').replace(/\/definitions\//, ''), 127 | ), 128 | undefined, 129 | ) 130 | } else if (schema.oneOf) { 131 | return ts.createUnionTypeNode( 132 | schema.oneOf.map((schema) => schemaToTypeNode(schema as JSONSchema7)), 133 | ) 134 | } else { 135 | return ts.createTypeLiteralNode(undefined) 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * Transforms a JSON Schema to TypeScript AST, but this time as a top 142 | * level exported type declaration, i.e. `export type Foo = { }`. 143 | */ 144 | export const schemaToTopLevelDeclaration = ( 145 | schema: JSONSchema7, 146 | ): ts.DeclarationStatement => { 147 | const identifier = (schema.$id || 'Unknown').replace(/#/, '') 148 | let statement: ts.DeclarationStatement 149 | // @ts-ignore 150 | const enumDescriptions: string[] = schema.enumDescriptions 151 | if (schema.enum && enumDescriptions) { 152 | statement = ts.createEnumDeclaration( 153 | undefined, 154 | [ts.createModifier(ts.SyntaxKind.ExportKeyword)], 155 | ts.createIdentifier(identifier), 156 | schema.enum.map((item, index) => 157 | ts.createEnumMember( 158 | ts.createIdentifier( 159 | pascalize(enumDescriptions[index]).replace(/\W/, ''), 160 | ), 161 | ts.createLiteral(item as string), 162 | ), 163 | ), 164 | ) 165 | } else { 166 | statement = ts.createTypeAliasDeclaration( 167 | undefined, 168 | [ts.createModifier(ts.SyntaxKind.ExportKeyword)], 169 | ts.createIdentifier(identifier), 170 | undefined, 171 | schemaToTypeNode(schema), 172 | ) 173 | } 174 | 175 | if (schema.description) { 176 | ts.addSyntheticLeadingComment( 177 | statement, 178 | ts.SyntaxKind.MultiLineCommentTrivia, 179 | `*\n * ${schema.description}\n `, 180 | true, 181 | ) 182 | } 183 | return statement 184 | } 185 | 186 | /** 187 | * Use the presence of `do_objectID` and `frame` properties as a heuristic to 188 | * identify a schema that represents a layer. 189 | */ 190 | export const isLayerSchema = (schema: JSONSchema7) => { 191 | const hasFrame = 192 | schema.properties && typeof schema.properties.frame === 'object' 193 | const hasId = 194 | schema.properties && typeof schema.properties.do_objectID === 'object' 195 | return hasFrame && hasId 196 | } 197 | 198 | /** 199 | * Use layeriness and the presence of a `layers` array as a heruistic to 200 | * identify a schema that represents a group. 201 | */ 202 | export const isGroupSchema = (schema: JSONSchema7) => { 203 | const isLayer = isLayerSchema(schema) 204 | const hasLayers = 205 | schema.properties && typeof schema.properties.layers === 'object' 206 | return isLayer && hasLayers 207 | } 208 | 209 | /** 210 | * Does the schema represent an object/class in the model? 211 | */ 212 | export const isObjectSchema = (schema: JSONSchema7) => { 213 | return schema.properties && '_class' in schema.properties 214 | } 215 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist/esm", 5 | "module": "ES6" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "noImplicitReturns": true, 5 | "noUnusedLocals": true, 6 | "noUnusedParameters": true, 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "outDir": "dist/cjs", 10 | "target": "ES6", 11 | "module": "CommonJS", 12 | "moduleResolution": "node" 13 | }, 14 | "include": ["index.ts"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | --------------------------------------------------------------------------------