├── .circleci └── config.yml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── REPO_OWNER ├── jest.config.js ├── lint-staged.config.js ├── package.json ├── rollup.config.ts ├── src ├── contentful-typescript-codegen.ts ├── loadEnvironment.ts └── renderers │ ├── contentful-fields-only │ ├── fields │ │ ├── renderArray.ts │ │ ├── renderLink.ts │ │ └── renderRichText.ts │ └── renderContentType.ts │ ├── contentful │ ├── fields │ │ ├── renderArray.ts │ │ ├── renderBoolean.ts │ │ ├── renderLink.ts │ │ ├── renderLocation.ts │ │ ├── renderNumber.ts │ │ ├── renderObject.ts │ │ ├── renderRichText.ts │ │ └── renderSymbol.ts │ ├── renderAllLocales.ts │ ├── renderContentType.ts │ ├── renderContentTypeId.ts │ ├── renderContentfulImports.ts │ ├── renderDefaultLocale.ts │ ├── renderField.ts │ ├── renderLocalizedTypes.ts │ └── renderNamespace.ts │ ├── render.ts │ ├── renderFieldsOnly.ts │ ├── typescript │ ├── renderArrayOf.ts │ ├── renderInterface.ts │ ├── renderInterfaceProperty.ts │ └── renderUnion.ts │ └── utils.ts ├── test ├── loadEnvironment.test.ts ├── renderers │ ├── contentful-fields-only │ │ ├── fields │ │ │ ├── renderArray.test.ts │ │ │ ├── renderLink.test.ts │ │ │ └── renderRichText.test.ts │ │ └── renderContentType.test.ts │ ├── contentful │ │ ├── fields │ │ │ ├── renderArray.test.ts │ │ │ ├── renderBoolean.test.ts │ │ │ ├── renderLink.test.ts │ │ │ ├── renderLocation.test.ts │ │ │ ├── renderNumber.test.ts │ │ │ ├── renderObject.test.ts │ │ │ ├── renderRichText.test.ts │ │ │ └── renderSymbol.test.ts │ │ ├── renderAllLocales.test.ts │ │ ├── renderContentType.test.ts │ │ ├── renderContentfulImports.test.ts │ │ └── renderDefaultLocale.test.ts │ ├── render.test.ts │ ├── renderFieldsOnly.test.ts │ ├── typescript │ │ ├── renderArrayOf.test.ts │ │ ├── renderInterface.test.ts │ │ ├── renderInterfaceProperty.test.ts │ │ └── renderUnion.test.ts │ └── utils.test.ts └── support │ └── format.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/repo 5 | docker: 6 | - image: circleci/node:12.21 7 | 8 | jobs: 9 | test: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | 14 | - restore_cache: 15 | keys: 16 | - v1-dependencies-{{arch}}-{{ checksum "package.json" }} 17 | - v1-dependencies-{{arch}}- 18 | 19 | - run: yarn install 20 | 21 | - save_cache: 22 | key: v1-dependencies-{{arch}}-{{ checksum "package.json" }} 23 | paths: 24 | - node_modules 25 | 26 | - run: 27 | name: Check format 28 | command: yarn format:check 29 | 30 | - run: 31 | name: Run linter 32 | command: yarn lint 33 | 34 | - run: 35 | name: Run tests 36 | command: yarn test:ci 37 | 38 | release: 39 | <<: *defaults 40 | 41 | steps: 42 | - checkout 43 | - run: yarn install 44 | - run: yarn build 45 | - run: yarn semantic-release 46 | 47 | workflows: 48 | version: 2 49 | 50 | test: 51 | jobs: 52 | - test 53 | - release: 54 | requires: 55 | - test 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | compiled 9 | .awcache 10 | .rpt2_cache 11 | docs 12 | dist 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to an Intercom project. 4 | 5 | ## Table of contents 6 | 7 | - [Development](#development) 8 | - [Installation](#installation) 9 | - [Project structure](#project-structure) 10 | - [Code of Conduct](#code-of-conduct) 11 | - [Our pledge](#our-pledge) 12 | - [Our standards](#our-standards) 13 | - [Our responsibilities](#our-responsibilities) 14 | - [Scope](#scope) 15 | - [Enforcement](#enforcement) 16 | - [Attribution](#attribution) 17 | 18 | ## Development 19 | 20 | ### Installation 21 | 22 | This project is a Rollup-based TypeScript NodeJS library. To get the source and make changes: 23 | 24 | ```bash 25 | git clone git@github.com:intercom/contentful-typescript-codegen.git 26 | cd contentful-typescript-codegen 27 | yarn install 28 | ``` 29 | 30 | To ensure everything is set up correctly: 31 | 32 | ```bash 33 | yarn test 34 | ``` 35 | 36 | ### Project structure 37 | 38 | This project consists of three main parts, and the `src/` folder mostly reflects this: 39 | 40 | 1. The **CLI entry point**, which is responsible for parsing command line arguments, loading the 41 | Contentful environment from the parent module's setup, and printing the generated TypeScript to 42 | the specified output location. 43 | 1. The **TypeScript code generators**, which are helpers that print out things like interfaces, 44 | union types, and other TypeScript type structures. 45 | 1. The **Contentful code generators**, which receive Contentful objects (like Content Types and 46 | Fields) and use the **TypeScript code generators** to turn them into interfaces. 47 | 48 | The TypeScript code generators utilize one another to eventually resolve a giant string. This big, 49 | ugly string is ultimately passed through Prettier and sent back to the CLI layer to print to a file. 50 | 51 | All generators have roughly the following shape: 52 | 53 | ```ts 54 | interface Generator { 55 | (thing: SomeParticularObject, options?: OptionsForGenerator): string 56 | } 57 | ``` 58 | 59 | Testing mostly consists of snapshot testing. Tests use `prettier`, where applicable, to make the 60 | snapshots easier to read and to make sure they parse as valid TypeScript. Note that using `prettier` 61 | on too "narrow" of a test (i.e., _just_ a field) will cause a parser error. In such cases, just let 62 | the snapshot be ugly and let "broader" tests handle regressions involving potentially unparseable 63 | code. 64 | 65 | ## Code of Conduct 66 | 67 | ### Our pledge 68 | 69 | In the interest of fostering an open and welcoming environment, we as 70 | contributors and maintainers pledge to making participation in our project and 71 | our community a harassment-free experience for everyone, regardless of age, body 72 | size, disability, ethnicity, sex characteristics, gender identity and expression, 73 | level of experience, education, socio-economic status, nationality, personal 74 | appearance, race, religion, or sexual identity and orientation. 75 | 76 | ### Our standards 77 | 78 | Examples of behavior that contributes to creating a positive environment 79 | include: 80 | 81 | - Using welcoming and inclusive language 82 | - Being respectful of differing viewpoints and experiences 83 | - Gracefully accepting constructive criticism 84 | - Focusing on what is best for the community 85 | - Showing empathy towards other community members 86 | 87 | Examples of unacceptable behavior by participants include: 88 | 89 | - The use of sexualized language or imagery and unwelcome sexual attention or 90 | advances 91 | - Trolling, insulting/derogatory comments, and personal or political attacks 92 | - Public or private harassment 93 | - Publishing others' private information, such as a physical or electronic 94 | address, without explicit permission 95 | - Other conduct which could reasonably be considered inappropriate in a 96 | professional setting 97 | 98 | ### Our responsibilities 99 | 100 | Project maintainers are responsible for clarifying the standards of acceptable 101 | behavior and are expected to take appropriate and fair corrective action in 102 | response to any instances of unacceptable behavior. 103 | 104 | Project maintainers have the right and responsibility to remove, edit, or 105 | reject comments, commits, code, wiki edits, issues, and other contributions 106 | that are not aligned to this Code of Conduct, or to ban temporarily or 107 | permanently any contributor for other behaviors that they deem inappropriate, 108 | threatening, offensive, or harmful. 109 | 110 | ### Scope 111 | 112 | This Code of Conduct applies within all project spaces, and it also applies when 113 | an individual is representing the project or its community in public spaces. 114 | Examples of representing a project or community include using an official 115 | project e-mail address, posting via an official social media account, or acting 116 | as an appointed representative at an online or offline event. Representation of 117 | a project may be further defined and clarified by project maintainers. 118 | 119 | ### Enforcement 120 | 121 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 122 | reported by contacting the project team at team-web@intercom.io. All 123 | complaints will be reviewed and investigated and will result in a response that 124 | is deemed necessary and appropriate to the circumstances. The project team is 125 | obligated to maintain confidentiality with regard to the reporter of an incident. 126 | Further details of specific enforcement policies may be posted separately. 127 | 128 | Project maintainers who do not follow or enforce the Code of Conduct in good 129 | faith may face temporary or permanent repercussions as determined by other 130 | members of the project's leadership. 131 | 132 | ### Attribution 133 | 134 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 135 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 136 | 137 | [homepage]: https://www.contributor-covenant.org 138 | 139 | For answers to common questions about this code of conduct, see 140 | https://www.contributor-covenant.org/faq 141 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2019 Intercom 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contentful-typescript-codegen 2 | 3 | Generate typings from your Contentful environment. 4 | 5 | - Content Types become interfaces. 6 | - Locales (and your default locale) become string types. 7 | - Assets and Rich Text link to Contentful's types. 8 | 9 | At Intercom, we use this in our [website] to increase developer confidence and productivity, 10 | ensure that breaking changes to our Content Types don't cause an outage, and because it's neat. 11 | 12 | [website]: https://www.intercom.com 13 | 14 | ## Usage 15 | 16 | ```sh 17 | yarn add --dev contentful-typescript-codegen 18 | ``` 19 | 20 | Then, add the following to your `package.json`: 21 | 22 | ```jsonc 23 | { 24 | // ... 25 | "scripts": { 26 | "contentful-typescript-codegen": "contentful-typescript-codegen --output @types/generated/contentful.d.ts" 27 | } 28 | } 29 | ``` 30 | 31 | Feel free to change the output path to whatever you like. 32 | 33 | Next, the codegen will expect you to have created a file called either `getContentfulEnvironment.js` or `getContentfulEnvironment.ts` 34 | in the root of your project directory, which should export a promise that resolves with your Contentful environment. 35 | 36 | The reason for this is that you can do whatever you like to set up your Contentful Management 37 | Client. Here's an example of a JavaScript config: 38 | 39 | ```js 40 | const contentfulManagement = require("contentful-management") 41 | 42 | module.exports = function() { 43 | const contentfulClient = contentfulManagement.createClient({ 44 | accessToken: process.env.CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN, 45 | }) 46 | 47 | return contentfulClient 48 | .getSpace(process.env.CONTENTFUL_SPACE_ID) 49 | .then(space => space.getEnvironment(process.env.CONTENTFUL_ENVIRONMENT)) 50 | } 51 | ``` 52 | 53 | And the same example in TypeScript: 54 | 55 | ```ts 56 | import { strict as assert } from "assert" 57 | import contentfulManagement from "contentful-management" 58 | import { EnvironmentGetter } from "contentful-typescript-codegen" 59 | 60 | const { CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN, CONTENTFUL_SPACE_ID, CONTENTFUL_ENVIRONMENT } = process.env 61 | 62 | assert(CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN) 63 | assert(CONTENTFUL_SPACE_ID) 64 | assert(CONTENTFUL_ENVIRONMENT) 65 | 66 | const getContentfulEnvironment: EnvironmentGetter = () => { 67 | const contentfulClient = contentfulManagement.createClient({ 68 | accessToken: CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN, 69 | }) 70 | 71 | return contentfulClient 72 | .getSpace(CONTENTFUL_SPACE_ID) 73 | .then(space => space.getEnvironment(CONTENTFUL_ENVIRONMENT)) 74 | } 75 | 76 | module.exports = getContentfulEnvironment 77 | ``` 78 | 79 | > **Note** 80 | > 81 | > `ts-node` must be installed to use a TypeScript config 82 | 83 | ### Command line options 84 | 85 | ``` 86 | Usage 87 | $ contentful-typescript-codegen --output 88 | 89 | Options 90 | --output, -o Where to write to 91 | --poll, -p Continuously refresh types 92 | --interval N, -i The interval in seconds at which to poll (defaults to 15) 93 | ``` 94 | 95 | ## Example output 96 | 97 | Here's an idea of what the output will look like for a Content Type: 98 | 99 | ```ts 100 | interface IBlogPostFields { 101 | /** Title */ 102 | title: string 103 | 104 | /** Body */ 105 | body: Document 106 | 107 | /** Author link */ 108 | author: IAuthor 109 | 110 | /** Image */ 111 | image: Asset 112 | 113 | /** Published? */ 114 | published: boolean | null 115 | 116 | /** Tags */ 117 | tags: string[] 118 | 119 | /** Blog CTA variant */ 120 | ctaVariant: "new-cta" | "old-cta" 121 | } 122 | 123 | /** 124 | * A blog post. 125 | */ 126 | export interface IBlogPost extends Entry {} 127 | ``` 128 | 129 | You can see that a few things are handled for you: 130 | 131 | - Documentation comments are automatically generated from Contentful descriptions. 132 | - Links, like `author`, are resolved to other TypeScript interfaces. 133 | - Assets are handled properly. 134 | - Validations on symbols and text fields are expanded to unions. 135 | - Non-required attributes automatically have `| null` appended to their type. 136 | - The output is formatted using **your** Prettier config. 137 | -------------------------------------------------------------------------------- /REPO_OWNER: -------------------------------------------------------------------------------- 1 | team-web 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | ".(ts|tsx)": "ts-jest", 4 | }, 5 | testEnvironment: "node", 6 | testRegex: "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 7 | moduleFileExtensions: ["ts", "tsx", "js"], 8 | coveragePathIgnorePatterns: ["/node_modules/", "/test/", "/src/contentful-typescript-codegen.ts"], 9 | coverageThreshold: { 10 | global: { 11 | branches: 90, 12 | functions: 95, 13 | lines: 95, 14 | statements: 95, 15 | }, 16 | }, 17 | collectCoverageFrom: ["src/**/*.{js,ts}"], 18 | } 19 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "{src,test}/**/*.ts": ["prettier --write", "git add"], 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contentful-typescript-codegen", 3 | "description": "Generate TypeScript types from your Contentful environment.", 4 | "license": "MIT", 5 | "author": "Steven Petryk ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/intercom/contentful-typescript-codegen" 9 | }, 10 | "resolutions": { 11 | "braces": "3.0.3", 12 | "ansi-regex": "3.0.1", 13 | "minimist": "1.2.6", 14 | "micromatch": "4.0.8", 15 | "cross-spawn": "7.0.6" 16 | }, 17 | "version": "0.0.1-development", 18 | "main": "dist/contentful-typescript-codegen.js", 19 | "bin": "./dist/contentful-typescript-codegen.js", 20 | "files": [ 21 | "dist" 22 | ], 23 | "scripts": { 24 | "prebuild": "rimraf dist", 25 | "build": "tsc --module commonjs && rollup -c rollup.config.ts && chmod +x dist/contentful-typescript-codegen.js", 26 | "commit": "git-cz", 27 | "format": "prettier --write \"**/*.ts\"", 28 | "format:check": "npm run format -- --check", 29 | "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 30 | "start": "rollup -c rollup.config.ts -w", 31 | "test": "jest --coverage", 32 | "test:ci": "jest --coverage --max-workers=2 --ci", 33 | "test:prod": "npm run lint && npm run test -- --no-cache", 34 | "test:watch": "jest --coverage --watch", 35 | "semantic-release": "semantic-release" 36 | }, 37 | "typings": "dist/types/contentful-typescript-codegen.d.ts", 38 | "dependencies": { 39 | "fs-extra": "^9.1.0", 40 | "lodash": "^4.17.21", 41 | "meow": "^9.0.0" 42 | }, 43 | "peerDependencies": { 44 | "prettier": ">= 1", 45 | "ts-node": ">= 9.0.0" 46 | }, 47 | "peerDependenciesMeta": { 48 | "ts-node": { 49 | "optional": true 50 | } 51 | }, 52 | "devDependencies": { 53 | "@contentful/rich-text-types": "^13.4.0", 54 | "@types/fs-extra": "^9.0.8", 55 | "@types/jest": "^26.0.24", 56 | "@types/lodash": "^4.14.168", 57 | "@types/meow": "^5.0.0", 58 | "@types/node": "^14.14.32", 59 | "@types/prettier": "^1.18.0", 60 | "contentful": "^8.1.9", 61 | "cz-conventional-changelog": "^3.3.0", 62 | "husky": "^3.0.2", 63 | "jest": "^26.6.3", 64 | "lint-staged": "^9.5.0", 65 | "prettier": "1.x", 66 | "rimraf": "^2.6.3", 67 | "rollup": "^2.79.2", 68 | "rollup-plugin-commonjs": "^10.0.1", 69 | "rollup-plugin-json": "^4.0.0", 70 | "rollup-plugin-node-resolve": "^5.2.0", 71 | "rollup-plugin-sourcemaps": "^0.4.2", 72 | "rollup-plugin-typescript2": "^0.22.1", 73 | "semantic-release": "^17.4.7", 74 | "ts-jest": "^26.0.0", 75 | "ts-node": "^10.6.0", 76 | "tslint": "^5.18.0", 77 | "tslint-config-prettier": "^1.18.0", 78 | "tslint-config-standard": "^8.0.1", 79 | "typescript": "^3.8.0" 80 | }, 81 | "keywords": [], 82 | "engines": { 83 | "node": ">=6.0.0" 84 | }, 85 | "husky": { 86 | "hooks": { 87 | "pre-commit": "lint-staged" 88 | } 89 | }, 90 | "config": { 91 | "commitizen": { 92 | "path": "./node_modules/cz-conventional-changelog" 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve" 2 | import commonjs from "rollup-plugin-commonjs" 3 | import sourceMaps from "rollup-plugin-sourcemaps" 4 | import typescript from "rollup-plugin-typescript2" 5 | import json from "rollup-plugin-json" 6 | 7 | const pkg = require("./package.json") 8 | 9 | const libraryName = "contentful-typescript-codegen" 10 | 11 | export default { 12 | input: `src/${libraryName}.ts`, 13 | output: [{ file: pkg.main, format: "cjs", sourcemap: true, banner: "#!/usr/bin/env node" }], 14 | external: ["prettier", "lodash", "path", "fs", "fs-extra", "meow"], 15 | watch: { 16 | include: "src/**", 17 | }, 18 | plugins: [ 19 | json(), 20 | typescript({ useTsconfigDeclarationDir: true }), 21 | commonjs({ 22 | namedExports: { 23 | "node_modules/prettier/index.js": ["format", "resolveConfig"], 24 | "node_modules/lodash/lodash.js": ["camelCase", "upperFirst"], 25 | }, 26 | }), 27 | resolve(), 28 | sourceMaps(), 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /src/contentful-typescript-codegen.ts: -------------------------------------------------------------------------------- 1 | import render from "./renderers/render" 2 | import renderFieldsOnly from "./renderers/renderFieldsOnly" 3 | import path from "path" 4 | import { outputFileSync } from "fs-extra" 5 | import { loadEnvironment } from "./loadEnvironment" 6 | 7 | const meow = require("meow") 8 | 9 | export { ContentfulEnvironment, EnvironmentGetter } from "./loadEnvironment" 10 | 11 | const cli = meow( 12 | ` 13 | Usage 14 | $ contentful-typescript-codegen --output 15 | 16 | Options 17 | --output, -o Where to write to 18 | --poll, -p Continuously refresh types 19 | --interval N, -i The interval in seconds at which to poll (defaults to 15) 20 | --namespace N, -n Wrap types in namespace N (disabled by default) 21 | --fields-only Output a tree that _only_ ensures fields are valid 22 | and present, and does not provide types for Sys, 23 | Assets, or Rich Text. This is useful for ensuring raw 24 | Contentful responses will be compatible with your code. 25 | --localization -l Output fields with localized values 26 | 27 | Examples 28 | $ contentful-typescript-codegen -o src/@types/generated/contentful.d.ts 29 | `, 30 | { 31 | flags: { 32 | output: { 33 | type: "string", 34 | alias: "o", 35 | isRequired: true, 36 | }, 37 | fieldsOnly: { 38 | type: "boolean", 39 | isRequired: false, 40 | }, 41 | poll: { 42 | type: "boolean", 43 | alias: "p", 44 | isRequired: false, 45 | }, 46 | interval: { 47 | type: "string", 48 | alias: "i", 49 | isRequired: false, 50 | }, 51 | namespace: { 52 | type: "string", 53 | alias: "n", 54 | isRequired: false, 55 | }, 56 | localization: { 57 | type: "boolean", 58 | alias: "l", 59 | isRequired: false, 60 | }, 61 | }, 62 | }, 63 | ) 64 | 65 | async function runCodegen(outputFile: string) { 66 | const { contentTypes, locales } = await loadEnvironment() 67 | const outputPath = path.resolve(process.cwd(), outputFile) 68 | 69 | let output 70 | if (cli.flags.fieldsOnly) { 71 | output = await renderFieldsOnly(contentTypes.items, { namespace: cli.flags.namespace }) 72 | } else { 73 | output = await render(contentTypes.items, locales.items, { 74 | localization: cli.flags.localization, 75 | namespace: cli.flags.namespace, 76 | }) 77 | } 78 | 79 | outputFileSync(outputPath, output) 80 | } 81 | 82 | runCodegen(cli.flags.output).catch(error => { 83 | console.error(error) 84 | process.exit(1) 85 | }) 86 | 87 | if (cli.flags.poll) { 88 | const intervalInSeconds = parseInt(cli.flags.interval, 10) 89 | 90 | if (!isNaN(intervalInSeconds) && intervalInSeconds > 0) { 91 | setInterval(() => runCodegen(cli.flags.output), intervalInSeconds * 1000) 92 | } else { 93 | throw new Error(`Expected a positive numeric interval, but got ${cli.flags.interval}`) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/loadEnvironment.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path" 2 | import * as fs from "fs" 3 | import { ContentfulCollection, ContentTypeCollection, LocaleCollection } from "contentful" 4 | 5 | // todo: switch to contentful-management interfaces here 6 | export interface ContentfulEnvironment { 7 | getContentTypes(options: { limit: number }): Promise> 8 | getLocales(): Promise> 9 | } 10 | 11 | export type EnvironmentGetter = () => Promise 12 | 13 | export async function loadEnvironment() { 14 | try { 15 | const getEnvironment = getEnvironmentGetter() 16 | const environment = await getEnvironment() 17 | 18 | return { 19 | contentTypes: (await environment.getContentTypes({ limit: 1000 })) as ContentTypeCollection, 20 | locales: (await environment.getLocales()) as LocaleCollection, 21 | } 22 | } finally { 23 | if (registerer) { 24 | registerer.enabled(false) 25 | } 26 | } 27 | } 28 | 29 | /* istanbul ignore next */ 30 | const interopRequireDefault = (obj: any): { default: any } => 31 | obj && obj.__esModule ? obj : { default: obj } 32 | 33 | type Registerer = { enabled(value: boolean): void } 34 | 35 | let registerer: Registerer | null = null 36 | 37 | function enableTSNodeRegisterer() { 38 | if (registerer) { 39 | registerer.enabled(true) 40 | 41 | return 42 | } 43 | 44 | try { 45 | registerer = require("ts-node").register() as Registerer 46 | registerer.enabled(true) 47 | } catch (e) { 48 | if (e.code === "MODULE_NOT_FOUND") { 49 | throw new Error( 50 | `'ts-node' is required for TypeScript configuration files. Make sure it is installed\nError: ${e.message}`, 51 | ) 52 | } 53 | 54 | throw e 55 | } 56 | } 57 | 58 | function determineEnvironmentPath() { 59 | const pathWithoutExtension = path.resolve(process.cwd(), "./getContentfulEnvironment") 60 | 61 | if (fs.existsSync(`${pathWithoutExtension}.ts`)) { 62 | return `${pathWithoutExtension}.ts` 63 | } 64 | 65 | return `${pathWithoutExtension}.js` 66 | } 67 | 68 | function getEnvironmentGetter(): EnvironmentGetter { 69 | const getEnvironmentPath = determineEnvironmentPath() 70 | 71 | if (getEnvironmentPath.endsWith(".ts")) { 72 | enableTSNodeRegisterer() 73 | 74 | return interopRequireDefault(require(getEnvironmentPath)).default 75 | } 76 | 77 | return require(getEnvironmentPath) 78 | } 79 | -------------------------------------------------------------------------------- /src/renderers/contentful-fields-only/fields/renderArray.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | import renderSymbol from "../../contentful/fields/renderSymbol" 3 | import renderLink from "../../contentful-fields-only/fields/renderLink" 4 | import renderArrayOf from "../../typescript/renderArrayOf" 5 | 6 | export default function renderArray(field: Field): string { 7 | if (!field.items) { 8 | throw new Error(`Cannot render non-array field ${field.id} as an array`) 9 | } 10 | 11 | const fieldWithValidations: Field = { 12 | ...field, 13 | linkType: field.items.linkType, 14 | validations: field.items.validations || [], 15 | } 16 | 17 | switch (field.items.type) { 18 | case "Symbol": { 19 | return renderArrayOf(renderSymbol(fieldWithValidations)) 20 | } 21 | 22 | case "Link": { 23 | return renderArrayOf(renderLink(fieldWithValidations)) 24 | } 25 | } 26 | 27 | return renderArrayOf("unknown") 28 | } 29 | -------------------------------------------------------------------------------- /src/renderers/contentful-fields-only/fields/renderLink.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | import renderContentTypeId from "../../contentful/renderContentTypeId" 3 | import { renderUnionValues } from "../../typescript/renderUnion" 4 | 5 | export default function renderLink(field: Field): string { 6 | if (field.linkType === "Asset") { 7 | return "any" 8 | } 9 | 10 | if (field.linkType === "Entry") { 11 | const contentTypeValidation = field.validations.find(validation => !!validation.linkContentType) 12 | 13 | if (contentTypeValidation) { 14 | return renderUnionValues(contentTypeValidation.linkContentType!.map(renderContentTypeId)) 15 | } else { 16 | return "unknown" 17 | } 18 | } 19 | 20 | return "unknown" 21 | } 22 | -------------------------------------------------------------------------------- /src/renderers/contentful-fields-only/fields/renderRichText.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | export default function renderRichText(field: Field): string { 4 | return "any" 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful-fields-only/renderContentType.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, Field, FieldType } from "contentful" 2 | 3 | import renderInterface from "../typescript/renderInterface" 4 | import renderField from "../contentful/renderField" 5 | import renderContentTypeId from "../contentful/renderContentTypeId" 6 | 7 | import renderArray from "../contentful-fields-only/fields/renderArray" 8 | import renderLink from "../contentful-fields-only/fields/renderLink" 9 | import renderRichText from "../contentful-fields-only/fields/renderRichText" 10 | 11 | import renderBoolean from "../contentful/fields/renderBoolean" 12 | import renderLocation from "../contentful/fields/renderLocation" 13 | import renderNumber from "../contentful/fields/renderNumber" 14 | import renderObject from "../contentful/fields/renderObject" 15 | import renderSymbol from "../contentful/fields/renderSymbol" 16 | 17 | export default function renderContentType(contentType: ContentType): string { 18 | const name = renderContentTypeId(contentType.sys.id) 19 | const fields = renderContentTypeFields(contentType.fields) 20 | 21 | return renderInterface({ 22 | name, 23 | fields: ` 24 | fields: { ${fields} }; 25 | [otherKeys: string]: any; 26 | `, 27 | }) 28 | } 29 | 30 | function renderContentTypeFields(fields: Field[]): string { 31 | return fields 32 | .filter(field => !field.omitted) 33 | .map(field => { 34 | const functionMap: Record string> = { 35 | Array: renderArray, 36 | Boolean: renderBoolean, 37 | Date: renderSymbol, 38 | Integer: renderNumber, 39 | Link: renderLink, 40 | Location: renderLocation, 41 | Number: renderNumber, 42 | Object: renderObject, 43 | RichText: renderRichText, 44 | Symbol: renderSymbol, 45 | Text: renderSymbol, 46 | } 47 | 48 | return renderField(field, functionMap[field.type](field)) 49 | }) 50 | .join("\n\n") 51 | } 52 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderArray.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | import renderSymbol from "./renderSymbol" 3 | import renderLink from "./renderLink" 4 | import renderArrayOf from "../../typescript/renderArrayOf" 5 | 6 | export default function renderArray(field: Field): string { 7 | if (!field.items) { 8 | throw new Error(`Cannot render non-array field ${field.id} as an array`) 9 | } 10 | 11 | const fieldWithValidations: Field = { 12 | ...field, 13 | linkType: field.items.linkType, 14 | validations: field.items.validations || [], 15 | } 16 | 17 | switch (field.items.type) { 18 | case "Symbol": { 19 | return renderArrayOf(renderSymbol(fieldWithValidations)) 20 | } 21 | 22 | case "Link": { 23 | return renderArrayOf(renderLink(fieldWithValidations)) 24 | } 25 | } 26 | 27 | return renderArrayOf("unknown") 28 | } 29 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderBoolean.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | export default function renderBoolean(field: Field) { 4 | return "boolean" 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderLink.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | import renderContentTypeId from "../renderContentTypeId" 3 | import { renderUnionValues } from "../../typescript/renderUnion" 4 | 5 | export default function renderLink(field: Field): string { 6 | if (field.linkType === "Asset") { 7 | return "Asset" 8 | } 9 | 10 | if (field.linkType === "Entry") { 11 | const contentTypeValidation = field.validations.find(validation => !!validation.linkContentType) 12 | 13 | if (contentTypeValidation) { 14 | return renderUnionValues(contentTypeValidation.linkContentType!.map(renderContentTypeId)) 15 | } else { 16 | return "Entry<{ [fieldId: string]: unknown }>" 17 | } 18 | } 19 | 20 | return "unknown" 21 | } 22 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderLocation.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | export default function renderLocation(field: Field): string { 4 | return "{ lat: number, lon: number }" 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderNumber.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | export default function renderNumber(field: Field): string { 4 | return "number" 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderObject.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | export default function renderObject(field: Field): string { 4 | return "Record" 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderRichText.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | export default function renderRichText(field: Field): string { 4 | return "Document" 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful/fields/renderSymbol.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | 3 | import { renderUnionValues } from "../../typescript/renderUnion" 4 | import { escapeSingleQuotes } from "../../utils" 5 | 6 | export default function renderSymbol(field: Field) { 7 | const inValidation = field.validations.find(validation => !!validation.in) 8 | 9 | if (inValidation) { 10 | return renderUnionValues(inValidation.in!.map(value => `'${escapeSingleQuotes(value)}'`)) 11 | } else { 12 | return "string" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderAllLocales.ts: -------------------------------------------------------------------------------- 1 | import renderUnion from "../typescript/renderUnion" 2 | import { Locale } from "contentful" 3 | 4 | export default function renderAllLocales(locales: Locale[]): string { 5 | return renderUnion( 6 | "LOCALE_CODE", 7 | locales.map(locale => `'${locale.code}'`), 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderContentType.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, Field, FieldType, Sys } from "contentful" 2 | 3 | import renderInterface from "../typescript/renderInterface" 4 | import renderField from "./renderField" 5 | import renderContentTypeId from "./renderContentTypeId" 6 | 7 | import renderArray from "./fields/renderArray" 8 | import renderBoolean from "./fields/renderBoolean" 9 | import renderLink from "./fields/renderLink" 10 | import renderLocation from "./fields/renderLocation" 11 | import renderNumber from "./fields/renderNumber" 12 | import renderObject from "./fields/renderObject" 13 | import renderRichText from "./fields/renderRichText" 14 | import renderSymbol from "./fields/renderSymbol" 15 | 16 | export default function renderContentType(contentType: ContentType, localization: boolean): string { 17 | const name = renderContentTypeId(contentType.sys.id) 18 | const fields = renderContentTypeFields(contentType.fields, localization) 19 | const sys = renderSys(contentType.sys) 20 | 21 | return ` 22 | ${renderInterface({ name: `${name}Fields`, fields })} 23 | 24 | ${descriptionComment(contentType.description)} 25 | ${renderInterface({ name, extension: `Entry<${name}Fields>`, fields: sys })} 26 | ` 27 | } 28 | 29 | function descriptionComment(description: string | undefined) { 30 | if (description) { 31 | return `/** ${description} */` 32 | } 33 | 34 | return "" 35 | } 36 | 37 | function renderContentTypeFields(fields: Field[], localization: boolean): string { 38 | return fields 39 | .filter(field => !field.omitted) 40 | .map(field => { 41 | const functionMap: Record string> = { 42 | Array: renderArray, 43 | Boolean: renderBoolean, 44 | Date: renderSymbol, 45 | Integer: renderNumber, 46 | Link: renderLink, 47 | Location: renderLocation, 48 | Number: renderNumber, 49 | Object: renderObject, 50 | RichText: renderRichText, 51 | Symbol: renderSymbol, 52 | Text: renderSymbol, 53 | } 54 | 55 | return renderField(field, functionMap[field.type](field), localization) 56 | }) 57 | .join("\n\n") 58 | } 59 | 60 | function renderSys(sys: Sys) { 61 | return ` 62 | sys: { 63 | id: string; 64 | type: string; 65 | createdAt: string; 66 | updatedAt: string; 67 | locale: string; 68 | contentType: { 69 | sys: { 70 | id: '${sys.id}'; 71 | linkType: 'ContentType'; 72 | type: 'Link'; 73 | } 74 | } 75 | } 76 | ` 77 | } 78 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderContentTypeId.ts: -------------------------------------------------------------------------------- 1 | import { upperFirst, camelCase } from "lodash" 2 | 3 | export default function renderContentTypeId(contentTypeId: string): string { 4 | return "I" + upperFirst(camelCase(contentTypeId)) 5 | } 6 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderContentfulImports.ts: -------------------------------------------------------------------------------- 1 | export default function renderContentfulImports( 2 | localization: boolean = false, 3 | hasRichText: boolean = true, 4 | ): string { 5 | return ` 6 | // THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 7 | 8 | import { ${localization ? "" : "Asset, "}Entry } from 'contentful' 9 | ${hasRichText ? "import { Document } from '@contentful/rich-text-types'" : ""} 10 | ` 11 | } 12 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderDefaultLocale.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from "contentful" 2 | 3 | export default function renderDefaultLocale(locales: Locale[]): string { 4 | const defaultLocale = locales.find(locale => locale.default) 5 | 6 | if (!defaultLocale) { 7 | throw new Error("Could not find a default locale in Contentful.") 8 | } 9 | 10 | return `export type CONTENTFUL_DEFAULT_LOCALE_CODE = '${defaultLocale.code}';` 11 | } 12 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderField.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "contentful" 2 | import renderInterfaceProperty from "../typescript/renderInterfaceProperty" 3 | 4 | export default function renderField( 5 | field: Field, 6 | type: string, 7 | localization: boolean = false, 8 | ): string { 9 | return renderInterfaceProperty(field.id, type, field.required, localization, field.name) 10 | } 11 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderLocalizedTypes.ts: -------------------------------------------------------------------------------- 1 | /** renders helper types for --localization flag */ 2 | export default function renderLocalizedTypes(localization: boolean) { 3 | if (!localization) return null 4 | 5 | return ` 6 | export type LocalizedField = Partial> 7 | 8 | // We have to use our own localized version of Asset because of a bug in contentful https://github.com/contentful/contentful.js/issues/208 9 | export interface Asset { 10 | sys: Sys 11 | fields: { 12 | title: LocalizedField 13 | description: LocalizedField 14 | file: LocalizedField<{ 15 | url: string 16 | details: { 17 | size: number 18 | image?: { 19 | width: number 20 | height: number 21 | } 22 | } 23 | fileName: string 24 | contentType: string 25 | }> 26 | } 27 | toPlainObject(): object 28 | } 29 | ` 30 | } 31 | -------------------------------------------------------------------------------- /src/renderers/contentful/renderNamespace.ts: -------------------------------------------------------------------------------- 1 | export default function renderNamespace(source: string, namespace: string | undefined) { 2 | if (!namespace) return source 3 | 4 | return ` 5 | declare namespace ${namespace} { 6 | ${source} 7 | } 8 | 9 | export as namespace ${namespace} 10 | export=${namespace} 11 | ` 12 | } 13 | -------------------------------------------------------------------------------- /src/renderers/render.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, Locale } from "contentful" 2 | 3 | import { format, resolveConfig } from "prettier" 4 | 5 | import renderAllLocales from "./contentful/renderAllLocales" 6 | import renderContentfulImports from "./contentful/renderContentfulImports" 7 | import renderContentType from "./contentful/renderContentType" 8 | import renderContentTypeId from "./contentful/renderContentTypeId" 9 | import renderDefaultLocale from "./contentful/renderDefaultLocale" 10 | import renderLocalizedTypes from "./contentful/renderLocalizedTypes" 11 | import renderNamespace from "./contentful/renderNamespace" 12 | import renderUnion from "./typescript/renderUnion" 13 | 14 | interface Options { 15 | localization?: boolean 16 | namespace?: string 17 | } 18 | 19 | export default async function render( 20 | contentTypes: ContentType[], 21 | locales: Locale[], 22 | { namespace, localization = false }: Options = {}, 23 | ) { 24 | const sortedContentTypes = contentTypes.sort((a, b) => a.sys.id.localeCompare(b.sys.id)) 25 | const sortedLocales = locales.sort((a, b) => a.code.localeCompare(b.code)) 26 | 27 | const typingsSource = [ 28 | renderAllContentTypes(sortedContentTypes, localization), 29 | renderAllContentTypeIds(sortedContentTypes), 30 | renderEntryType(sortedContentTypes), 31 | renderAllLocales(sortedLocales), 32 | renderDefaultLocale(sortedLocales), 33 | renderLocalizedTypes(localization), 34 | ].join("\n\n") 35 | 36 | const source = [ 37 | renderContentfulImports(localization, hasRichText(contentTypes)), 38 | renderNamespace(typingsSource, namespace), 39 | ].join("\n\n") 40 | 41 | const prettierConfig = await resolveConfig(process.cwd()) 42 | return format(source, { ...prettierConfig, parser: "typescript" }) 43 | } 44 | 45 | function renderAllContentTypes(contentTypes: ContentType[], localization: boolean): string { 46 | return contentTypes.map(contentType => renderContentType(contentType, localization)).join("\n\n") 47 | } 48 | 49 | function renderAllContentTypeIds(contentTypes: ContentType[]): string { 50 | return renderUnion( 51 | "CONTENT_TYPE", 52 | contentTypes.map(contentType => `'${contentType.sys.id}'`), 53 | ) 54 | } 55 | 56 | function renderEntryType(contentTypes: ContentType[]) { 57 | return renderUnion( 58 | "IEntry", 59 | contentTypes.map(contentType => renderContentTypeId(contentType.sys.id)), 60 | ) 61 | } 62 | 63 | function hasRichText(contentTypes: ContentType[]): boolean { 64 | return contentTypes.some(sortedContentType => 65 | sortedContentType.fields.some(f => !f.omitted && f.type === "RichText"), 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /src/renderers/renderFieldsOnly.ts: -------------------------------------------------------------------------------- 1 | import { ContentType } from "contentful" 2 | 3 | import { format, resolveConfig } from "prettier" 4 | 5 | import renderContentType from "./contentful-fields-only/renderContentType" 6 | import renderNamespace from "./contentful/renderNamespace" 7 | 8 | interface Options { 9 | namespace?: string 10 | } 11 | 12 | export default async function renderFieldsOnly( 13 | contentTypes: ContentType[], 14 | { namespace }: Options = {}, 15 | ) { 16 | const sortedContentTypes = contentTypes.sort((a, b) => a.sys.id.localeCompare(b.sys.id)) 17 | 18 | const typingsSource = renderAllContentTypes(sortedContentTypes) 19 | const source = [renderNamespace(typingsSource, namespace)].join("\n\n") 20 | 21 | const prettierConfig = await resolveConfig(process.cwd()) 22 | 23 | return format(source, { ...prettierConfig, parser: "typescript" }) 24 | } 25 | 26 | function renderAllContentTypes(contentTypes: ContentType[]): string { 27 | return contentTypes.map(contentType => renderContentType(contentType)).join("\n\n") 28 | } 29 | -------------------------------------------------------------------------------- /src/renderers/typescript/renderArrayOf.ts: -------------------------------------------------------------------------------- 1 | export default function renderArrayOf(source: string) { 2 | return `(${source})[]` 3 | } 4 | -------------------------------------------------------------------------------- /src/renderers/typescript/renderInterface.ts: -------------------------------------------------------------------------------- 1 | export default function renderInterface({ 2 | name, 3 | extension, 4 | fields, 5 | description, 6 | }: { 7 | name: string 8 | extension?: string 9 | fields: string 10 | description?: string 11 | }) { 12 | return ` 13 | ${description ? `/** ${description} */` : ""} 14 | export interface ${name} ${extension ? `extends ${extension}` : ""} { 15 | ${fields} 16 | } 17 | ` 18 | } 19 | -------------------------------------------------------------------------------- /src/renderers/typescript/renderInterfaceProperty.ts: -------------------------------------------------------------------------------- 1 | export default function renderInterfaceProperty( 2 | name: string, 3 | type: string, 4 | required: boolean, 5 | localization: boolean, 6 | description?: string, 7 | ): string { 8 | return [ 9 | descriptionComment(description), 10 | name, 11 | required ? "" : "?", 12 | ": ", 13 | localization ? `LocalizedField<${type}>` : type, 14 | required ? "" : " | undefined", 15 | ";", 16 | ].join("") 17 | } 18 | 19 | function descriptionComment(description: string | undefined) { 20 | if (description) { 21 | return `/** ${description} */\n` 22 | } else { 23 | return "" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/renderers/typescript/renderUnion.ts: -------------------------------------------------------------------------------- 1 | export default function renderUnion(name: string, values: string[]): string { 2 | return ` 3 | export type ${name} = ${renderUnionValues(values)}; 4 | ` 5 | } 6 | 7 | export function renderUnionValues(values: string[]): string { 8 | if (values.length === 0) { 9 | return "never" 10 | } else { 11 | return values.join(" | ") 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/renderers/utils.ts: -------------------------------------------------------------------------------- 1 | export function escapeSingleQuotes(str: string = ""): string { 2 | return str.replace(/'/g, "\\'") 3 | } 4 | -------------------------------------------------------------------------------- /test/loadEnvironment.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs" 2 | import { loadEnvironment } from "../src/loadEnvironment" 3 | 4 | const contentfulEnvironment = () => ({ 5 | getContentTypes: () => [], 6 | getLocales: () => [], 7 | }) 8 | 9 | const getContentfulEnvironmentFileFactory = jest.fn((_type: string) => contentfulEnvironment) 10 | 11 | jest.mock( 12 | require("path").resolve(process.cwd(), "./getContentfulEnvironment.js"), 13 | () => getContentfulEnvironmentFileFactory("js"), 14 | { virtual: true }, 15 | ) 16 | 17 | jest.mock( 18 | require("path").resolve(process.cwd(), "./getContentfulEnvironment.ts"), 19 | () => getContentfulEnvironmentFileFactory("ts"), 20 | { virtual: true }, 21 | ) 22 | 23 | const tsNodeRegistererEnabled = jest.fn() 24 | const tsNodeRegister = jest.fn() 25 | 26 | jest.mock("ts-node", () => ({ register: tsNodeRegister })) 27 | 28 | describe("loadEnvironment", () => { 29 | beforeEach(() => { 30 | jest.resetAllMocks() 31 | jest.restoreAllMocks() 32 | jest.resetModules() 33 | 34 | getContentfulEnvironmentFileFactory.mockReturnValue(contentfulEnvironment) 35 | tsNodeRegister.mockReturnValue({ enabled: tsNodeRegistererEnabled }) 36 | }) 37 | 38 | describe("when getContentfulEnvironment.ts exists", () => { 39 | beforeEach(() => { 40 | jest.spyOn(fs, "existsSync").mockReturnValue(true) 41 | }) 42 | 43 | describe("when ts-node is not found", () => { 44 | beforeEach(() => { 45 | // technically this is throwing after the `require` call, 46 | // but it still tests the same code path so is fine 47 | tsNodeRegister.mockImplementation(() => { 48 | throw new (class extends Error { 49 | public code: string 50 | 51 | constructor(message?: string) { 52 | super(message) 53 | this.code = "MODULE_NOT_FOUND" 54 | } 55 | })() 56 | }) 57 | }) 58 | 59 | it("throws a nice error", async () => { 60 | await expect(loadEnvironment()).rejects.toThrow( 61 | "'ts-node' is required for TypeScript configuration files", 62 | ) 63 | }) 64 | }) 65 | 66 | describe("when there is another error", () => { 67 | beforeEach(() => { 68 | tsNodeRegister.mockImplementation(() => { 69 | throw new Error("something else went wrong!") 70 | }) 71 | }) 72 | 73 | it("re-throws", async () => { 74 | await expect(loadEnvironment()).rejects.toThrow("something else went wrong!") 75 | }) 76 | }) 77 | 78 | describe("when called multiple times", () => { 79 | it("re-uses the registerer", async () => { 80 | await loadEnvironment() 81 | await loadEnvironment() 82 | 83 | expect(tsNodeRegister).toHaveBeenCalledTimes(1) 84 | }) 85 | }) 86 | 87 | it("requires the typescript config", async () => { 88 | await loadEnvironment() 89 | 90 | expect(getContentfulEnvironmentFileFactory).toHaveBeenCalledWith("ts") 91 | expect(getContentfulEnvironmentFileFactory).not.toHaveBeenCalledWith("js") 92 | }) 93 | 94 | it("disables the registerer afterwards", async () => { 95 | await loadEnvironment() 96 | 97 | expect(tsNodeRegistererEnabled).toHaveBeenCalledWith(false) 98 | }) 99 | }) 100 | 101 | it("requires the javascript config", async () => { 102 | jest.spyOn(fs, "existsSync").mockReturnValue(false) 103 | 104 | await loadEnvironment() 105 | 106 | expect(getContentfulEnvironmentFileFactory).toHaveBeenCalledWith("js") 107 | expect(getContentfulEnvironmentFileFactory).not.toHaveBeenCalledWith("ts") 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /test/renderers/contentful-fields-only/fields/renderArray.test.ts: -------------------------------------------------------------------------------- 1 | import renderArray from "../../../../src/renderers/contentful-fields-only/fields/renderArray" 2 | import { Field } from "contentful" 3 | 4 | describe("renderArray()", () => { 5 | it("renders an array of symbols", () => { 6 | const arrayOfSymbols: Field = { 7 | type: "Array", 8 | id: "fieldId", 9 | name: "Field Name", 10 | validations: [], 11 | omitted: false, 12 | required: true, 13 | disabled: false, 14 | linkType: undefined, 15 | localized: false, 16 | items: { 17 | type: "Symbol", 18 | validations: [], 19 | }, 20 | } 21 | 22 | expect(renderArray(arrayOfSymbols)).toMatchInlineSnapshot(`"(string)[]"`) 23 | }) 24 | 25 | it("renders an array of symbols with validations", () => { 26 | const arrayOfValidatedSymbols: Field = { 27 | type: "Array", 28 | id: "fieldId", 29 | name: "Field Name", 30 | validations: [], 31 | omitted: false, 32 | required: true, 33 | disabled: false, 34 | linkType: undefined, 35 | localized: false, 36 | items: { 37 | type: "Symbol", 38 | validations: [{ in: ["one", "of", "these"] }], 39 | }, 40 | } 41 | 42 | expect(renderArray(arrayOfValidatedSymbols)).toMatchInlineSnapshot( 43 | `"('one' | 'of' | 'these')[]"`, 44 | ) 45 | }) 46 | 47 | it("renders an array of links of a particular type", () => { 48 | const arrayOfValidatedSymbols: Field = { 49 | type: "Array", 50 | id: "fieldId", 51 | name: "Field Name", 52 | validations: [], 53 | omitted: false, 54 | required: true, 55 | disabled: false, 56 | linkType: undefined, 57 | localized: false, 58 | items: { 59 | type: "Link", 60 | linkType: "Entry", 61 | validations: [{ linkContentType: ["contentType1", "contentType2"] }], 62 | }, 63 | } 64 | 65 | expect(renderArray(arrayOfValidatedSymbols)).toMatchInlineSnapshot( 66 | `"(IContentType1 | IContentType2)[]"`, 67 | ) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/renderers/contentful-fields-only/fields/renderLink.test.ts: -------------------------------------------------------------------------------- 1 | import renderLink from "../../../../src/renderers/contentful-fields-only/fields/renderLink" 2 | import { Field } from "contentful" 3 | 4 | describe("renderLink()", () => { 5 | it("renders a simple entry link", () => { 6 | const simpleEntryLink: Field = { 7 | id: "validatedEntryLink", 8 | name: "Entry Link", 9 | type: "Link", 10 | localized: false, 11 | required: false, 12 | validations: [], 13 | disabled: false, 14 | omitted: false, 15 | linkType: "Entry", 16 | } 17 | 18 | expect(renderLink(simpleEntryLink)).toMatchInlineSnapshot(`"unknown"`) 19 | }) 20 | 21 | it("renders a link with validations", () => { 22 | const validatedEntryLink: Field = { 23 | id: "validatedEntryLink", 24 | name: "Entry Link", 25 | type: "Link", 26 | localized: false, 27 | required: false, 28 | validations: [{ linkContentType: ["linkToOtherThing"] }], 29 | disabled: false, 30 | omitted: false, 31 | linkType: "Entry", 32 | } 33 | 34 | expect(renderLink(validatedEntryLink)).toMatchInlineSnapshot(`"ILinkToOtherThing"`) 35 | }) 36 | 37 | it("renders an asset link", () => { 38 | const assetLink: Field = { 39 | id: "assetLink", 40 | name: "Asset Link", 41 | type: "Link", 42 | linkType: "Asset", 43 | localized: false, 44 | required: true, 45 | validations: [], 46 | disabled: false, 47 | omitted: false, 48 | } 49 | 50 | expect(renderLink(assetLink)).toMatchInlineSnapshot(`"any"`) 51 | }) 52 | 53 | it("handles mysteries", () => { 54 | const mysteryLink: Field = { 55 | id: "mysteryLink", 56 | name: "Mystery Link", 57 | type: "Link", 58 | linkType: "Idk", 59 | localized: false, 60 | required: true, 61 | validations: [], 62 | disabled: false, 63 | omitted: false, 64 | } 65 | 66 | expect(renderLink(mysteryLink)).toMatchInlineSnapshot(`"unknown"`) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/renderers/contentful-fields-only/fields/renderRichText.test.ts: -------------------------------------------------------------------------------- 1 | import renderRichText from "../../../../src/renderers/contentful-fields-only/fields/renderRichText" 2 | import { Field } from "contentful" 3 | 4 | describe("renderRichText()", () => { 5 | const simpleRichText: Field = { 6 | type: "Object", 7 | id: "fieldId", 8 | name: "Field Name", 9 | validations: [], 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | it("works", () => { 18 | expect(renderRichText(simpleRichText).trim()).toMatchInlineSnapshot(`"any"`) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/renderers/contentful-fields-only/renderContentType.test.ts: -------------------------------------------------------------------------------- 1 | import renderContentType from "../../../src/renderers/contentful-fields-only/renderContentType" 2 | import { ContentType, Sys } from "contentful" 3 | import format from "../../support/format" 4 | 5 | describe("renderContentType()", () => { 6 | const contentType: ContentType = { 7 | sys: { 8 | id: "myContentType", 9 | } as Sys, 10 | fields: [ 11 | { 12 | id: "symbolField", 13 | name: "Symbol Field™", 14 | required: false, 15 | validations: [], 16 | disabled: false, 17 | omitted: false, 18 | localized: false, 19 | type: "Symbol", 20 | }, 21 | { 22 | id: "arrayField", 23 | name: "Array field", 24 | required: true, 25 | validations: [{}], 26 | items: { 27 | type: "Symbol", 28 | validations: [ 29 | { 30 | in: ["one", "of", "the", "above"], 31 | }, 32 | ], 33 | }, 34 | disabled: false, 35 | omitted: false, 36 | localized: false, 37 | type: "Array", 38 | }, 39 | ], 40 | description: "", 41 | displayField: "", 42 | name: "", 43 | toPlainObject: () => ({} as ContentType), 44 | } 45 | 46 | it("works with miscellaneous field types", () => { 47 | expect(format(renderContentType(contentType))).toMatchInlineSnapshot(` 48 | "export interface IMyContentType { 49 | fields: { 50 | /** Symbol Field™ */ 51 | symbolField?: string | undefined, 52 | 53 | /** Array field */ 54 | arrayField: (\\"one\\" | \\"of\\" | \\"the\\" | \\"above\\")[] 55 | }; 56 | [otherKeys: string]: any; 57 | }" 58 | `) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderArray.test.ts: -------------------------------------------------------------------------------- 1 | import renderArray from "../../../../src/renderers/contentful/fields/renderArray" 2 | import { Field } from "contentful" 3 | 4 | describe("renderArray()", () => { 5 | it("renders an array of symbols", () => { 6 | const arrayOfSymbols: Field = { 7 | type: "Array", 8 | id: "fieldId", 9 | name: "Field Name", 10 | validations: [], 11 | omitted: false, 12 | required: true, 13 | disabled: false, 14 | linkType: undefined, 15 | localized: false, 16 | items: { 17 | type: "Symbol", 18 | validations: [], 19 | }, 20 | } 21 | 22 | expect(renderArray(arrayOfSymbols)).toMatchInlineSnapshot(`"(string)[]"`) 23 | }) 24 | 25 | it("renders an array of symbols with validations", () => { 26 | const arrayOfValidatedSymbols: Field = { 27 | type: "Array", 28 | id: "fieldId", 29 | name: "Field Name", 30 | validations: [], 31 | omitted: false, 32 | required: true, 33 | disabled: false, 34 | linkType: undefined, 35 | localized: false, 36 | items: { 37 | type: "Symbol", 38 | validations: [{ in: ["one", "of", "these"] }], 39 | }, 40 | } 41 | 42 | expect(renderArray(arrayOfValidatedSymbols)).toMatchInlineSnapshot( 43 | `"('one' | 'of' | 'these')[]"`, 44 | ) 45 | }) 46 | 47 | it("renders an array of links of a particular type", () => { 48 | const arrayOfValidatedSymbols: Field = { 49 | type: "Array", 50 | id: "fieldId", 51 | name: "Field Name", 52 | validations: [], 53 | omitted: false, 54 | required: true, 55 | disabled: false, 56 | linkType: undefined, 57 | localized: false, 58 | items: { 59 | type: "Link", 60 | linkType: "Entry", 61 | validations: [{ linkContentType: ["contentType1", "contentType2"] }], 62 | }, 63 | } 64 | 65 | expect(renderArray(arrayOfValidatedSymbols)).toMatchInlineSnapshot( 66 | `"(IContentType1 | IContentType2)[]"`, 67 | ) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderBoolean.test.ts: -------------------------------------------------------------------------------- 1 | import renderBoolean from "../../../../src/renderers/contentful/fields/renderBoolean" 2 | import { Field } from "contentful" 3 | 4 | describe("renderSymbol()", () => { 5 | const simpleBoolean: Field = { 6 | type: "Boolean", 7 | id: "fieldId", 8 | name: "Field Name", 9 | validations: [], 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | it("works with simple booleans", () => { 18 | expect(renderBoolean(simpleBoolean).trim()).toMatchInlineSnapshot(`"boolean"`) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderLink.test.ts: -------------------------------------------------------------------------------- 1 | import renderLink from "../../../../src/renderers/contentful/fields/renderLink" 2 | import { Field } from "contentful" 3 | 4 | describe("renderLink()", () => { 5 | it("renders a simple entry link", () => { 6 | const simpleEntryLink: Field = { 7 | id: "validatedEntryLink", 8 | name: "Entry Link", 9 | type: "Link", 10 | localized: false, 11 | required: false, 12 | validations: [], 13 | disabled: false, 14 | omitted: false, 15 | linkType: "Entry", 16 | } 17 | 18 | expect(renderLink(simpleEntryLink)).toMatchInlineSnapshot( 19 | `"Entry<{ [fieldId: string]: unknown }>"`, 20 | ) 21 | }) 22 | 23 | it("renders a link with validations", () => { 24 | const validatedEntryLink: Field = { 25 | id: "validatedEntryLink", 26 | name: "Entry Link", 27 | type: "Link", 28 | localized: false, 29 | required: false, 30 | validations: [{ linkContentType: ["linkToOtherThing"] }], 31 | disabled: false, 32 | omitted: false, 33 | linkType: "Entry", 34 | } 35 | 36 | expect(renderLink(validatedEntryLink)).toMatchInlineSnapshot(`"ILinkToOtherThing"`) 37 | }) 38 | 39 | it("renders an asset link", () => { 40 | const assetLink: Field = { 41 | id: "assetLink", 42 | name: "Asset Link", 43 | type: "Link", 44 | linkType: "Asset", 45 | localized: false, 46 | required: true, 47 | validations: [], 48 | disabled: false, 49 | omitted: false, 50 | } 51 | 52 | expect(renderLink(assetLink)).toMatchInlineSnapshot(`"Asset"`) 53 | }) 54 | 55 | it("handles mysteries", () => { 56 | const mysteryLink: Field = { 57 | id: "mysteryLink", 58 | name: "Mystery Link", 59 | type: "Link", 60 | linkType: "Idk", 61 | localized: false, 62 | required: true, 63 | validations: [], 64 | disabled: false, 65 | omitted: false, 66 | } 67 | 68 | expect(renderLink(mysteryLink)).toMatchInlineSnapshot(`"unknown"`) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderLocation.test.ts: -------------------------------------------------------------------------------- 1 | import renderLocation from "../../../../src/renderers/contentful/fields/renderLocation" 2 | import { Field } from "contentful" 3 | 4 | describe("renderLocation()", () => { 5 | const simpleLocation: Field = { 6 | type: "Location", 7 | id: "fieldId", 8 | name: "Field Name", 9 | validations: [], 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | it("works", () => { 18 | expect(renderLocation(simpleLocation).trim()).toMatchInlineSnapshot( 19 | `"{ lat: number, lon: number }"`, 20 | ) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderNumber.test.ts: -------------------------------------------------------------------------------- 1 | import renderNumber from "../../../../src/renderers/contentful/fields/renderNumber" 2 | import { Field } from "contentful" 3 | 4 | describe("renderNumber()", () => { 5 | const simpleNumber: Field = { 6 | type: "Number", 7 | id: "fieldId", 8 | name: "Field Name", 9 | validations: [], 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | it("works with simple booleans", () => { 18 | expect(renderNumber(simpleNumber).trim()).toMatchInlineSnapshot(`"number"`) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderObject.test.ts: -------------------------------------------------------------------------------- 1 | import renderObject from "../../../../src/renderers/contentful/fields/renderObject" 2 | import { Field } from "contentful" 3 | 4 | describe("renderObject()", () => { 5 | const simpleObject: Field = { 6 | type: "Object", 7 | id: "fieldId", 8 | name: "Field Name", 9 | validations: [], 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | it("works", () => { 18 | expect(renderObject(simpleObject).trim()).toMatchInlineSnapshot(`"Record"`) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderRichText.test.ts: -------------------------------------------------------------------------------- 1 | import renderRichText from "../../../../src/renderers/contentful/fields/renderRichText" 2 | import { Field } from "contentful" 3 | 4 | describe("renderRichText()", () => { 5 | const simpleRichText: Field = { 6 | type: "Object", 7 | id: "fieldId", 8 | name: "Field Name", 9 | validations: [], 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | it("works", () => { 18 | expect(renderRichText(simpleRichText).trim()).toMatchInlineSnapshot(`"Document"`) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/renderers/contentful/fields/renderSymbol.test.ts: -------------------------------------------------------------------------------- 1 | import renderSymbol from "../../../../src/renderers/contentful/fields/renderSymbol" 2 | import { Field } from "contentful" 3 | 4 | describe("renderSymbol()", () => { 5 | const simpleString: Field = { 6 | type: "Symbol", 7 | validations: [], 8 | id: "fieldId", 9 | name: "Field Name", 10 | omitted: false, 11 | required: true, 12 | disabled: false, 13 | linkType: undefined, 14 | localized: false, 15 | } 16 | 17 | const stringWithValidations: Field = { 18 | type: "Symbol", 19 | validations: [{ in: ["one", "or", "the", "other"] }], 20 | id: "fieldId", 21 | name: "Field Name", 22 | omitted: false, 23 | required: true, 24 | disabled: false, 25 | linkType: undefined, 26 | localized: false, 27 | } 28 | 29 | it("works with simple strings", () => { 30 | expect(renderSymbol(simpleString).trim()).toMatchInlineSnapshot(`"string"`) 31 | }) 32 | 33 | it("works with strings with validations", () => { 34 | expect(renderSymbol(stringWithValidations).trim()).toMatchInlineSnapshot( 35 | `"'one' | 'or' | 'the' | 'other'"`, 36 | ) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test/renderers/contentful/renderAllLocales.test.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from "contentful" 2 | import format from "../../support/format" 3 | import renderAllLocales from "../../../src/renderers/contentful/renderAllLocales" 4 | 5 | describe("renderSymbol()", () => { 6 | const locales: Locale[] = [ 7 | { 8 | name: "English (US)", 9 | fallbackCode: null, 10 | code: "en-US", 11 | default: true, 12 | sys: {} as Locale["sys"], 13 | }, 14 | { 15 | name: "Brazilian Portuguese", 16 | fallbackCode: "en-US", 17 | code: "pt-BR", 18 | default: false, 19 | sys: {} as Locale["sys"], 20 | }, 21 | ] 22 | 23 | it("works with a list of locales", () => { 24 | expect(format(renderAllLocales(locales))).toMatchInlineSnapshot( 25 | `"export type LOCALE_CODE = \\"en-US\\" | \\"pt-BR\\";"`, 26 | ) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /test/renderers/contentful/renderContentType.test.ts: -------------------------------------------------------------------------------- 1 | import renderContentType from "../../../src/renderers/contentful/renderContentType" 2 | import { ContentType, Sys } from "contentful" 3 | import format from "../../support/format" 4 | 5 | describe("renderContentType()", () => { 6 | const contentType: ContentType = { 7 | sys: { 8 | id: "myContentType", 9 | } as Sys, 10 | fields: [ 11 | { 12 | id: "symbolField", 13 | name: "Symbol Field™", 14 | required: false, 15 | validations: [], 16 | disabled: false, 17 | omitted: false, 18 | localized: false, 19 | type: "Symbol", 20 | }, 21 | { 22 | id: "arrayField", 23 | name: "Array field", 24 | required: true, 25 | validations: [{}], 26 | items: { 27 | type: "Symbol", 28 | validations: [ 29 | { 30 | in: ["one", "of", "the", "above"], 31 | }, 32 | ], 33 | }, 34 | disabled: false, 35 | omitted: false, 36 | localized: false, 37 | type: "Array", 38 | }, 39 | ], 40 | description: "", 41 | displayField: "", 42 | name: "", 43 | toPlainObject: () => ({} as ContentType), 44 | } 45 | 46 | const contentTypeWithDescription: ContentType = { 47 | sys: { 48 | id: "myContentType", 49 | } as Sys, 50 | fields: [], 51 | description: "This is a description", 52 | displayField: "", 53 | name: "", 54 | toPlainObject: () => ({} as ContentType), 55 | } 56 | 57 | it("works with miscellaneous field types", () => { 58 | expect(format(renderContentType(contentType, false))).toMatchInlineSnapshot(` 59 | "export interface IMyContentTypeFields { 60 | /** Symbol Field™ */ 61 | symbolField?: string | undefined; 62 | 63 | /** Array field */ 64 | arrayField: (\\"one\\" | \\"of\\" | \\"the\\" | \\"above\\")[]; 65 | } 66 | 67 | export interface IMyContentType extends Entry { 68 | sys: { 69 | id: string, 70 | type: string, 71 | createdAt: string, 72 | updatedAt: string, 73 | locale: string, 74 | contentType: { 75 | sys: { 76 | id: \\"myContentType\\", 77 | linkType: \\"ContentType\\", 78 | type: \\"Link\\" 79 | } 80 | } 81 | }; 82 | }" 83 | `) 84 | }) 85 | 86 | it("supports descriptions", () => { 87 | expect(format(renderContentType(contentTypeWithDescription, false))).toMatchInlineSnapshot(` 88 | "export interface IMyContentTypeFields {} 89 | 90 | /** This is a description */ 91 | 92 | export interface IMyContentType extends Entry { 93 | sys: { 94 | id: string, 95 | type: string, 96 | createdAt: string, 97 | updatedAt: string, 98 | locale: string, 99 | contentType: { 100 | sys: { 101 | id: \\"myContentType\\", 102 | linkType: \\"ContentType\\", 103 | type: \\"Link\\" 104 | } 105 | } 106 | }; 107 | }" 108 | `) 109 | }) 110 | 111 | it("works with localized fields", () => { 112 | expect(format(renderContentType(contentType, true))).toMatchInlineSnapshot(` 113 | "export interface IMyContentTypeFields { 114 | /** Symbol Field™ */ 115 | symbolField?: LocalizedField | undefined; 116 | 117 | /** Array field */ 118 | arrayField: LocalizedField<(\\"one\\" | \\"of\\" | \\"the\\" | \\"above\\")[]>; 119 | } 120 | 121 | export interface IMyContentType extends Entry { 122 | sys: { 123 | id: string, 124 | type: string, 125 | createdAt: string, 126 | updatedAt: string, 127 | locale: string, 128 | contentType: { 129 | sys: { 130 | id: \\"myContentType\\", 131 | linkType: \\"ContentType\\", 132 | type: \\"Link\\" 133 | } 134 | } 135 | }; 136 | }" 137 | `) 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /test/renderers/contentful/renderContentfulImports.test.ts: -------------------------------------------------------------------------------- 1 | import renderContentfulImports from "../../../src/renderers/contentful/renderContentfulImports" 2 | import format from "../../support/format" 3 | 4 | describe("renderContentfulImports()", () => { 5 | it("renders the top of the codegen file", () => { 6 | expect(format(renderContentfulImports())).toMatchInlineSnapshot(` 7 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 8 | 9 | import { Asset, Entry } from \\"contentful\\"; 10 | import { Document } from \\"@contentful/rich-text-types\\";" 11 | `) 12 | }) 13 | 14 | it("renders the localized top of the codegen file", () => { 15 | expect(format(renderContentfulImports(true))).toMatchInlineSnapshot(` 16 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 17 | 18 | import { Entry } from \\"contentful\\"; 19 | import { Document } from \\"@contentful/rich-text-types\\";" 20 | `) 21 | }) 22 | 23 | it("renders the top of the codegen file without import 'Document' statement", () => { 24 | expect(format(renderContentfulImports(true, false))).toMatchInlineSnapshot(` 25 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 26 | 27 | import { Entry } from \\"contentful\\";" 28 | `) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/renderers/contentful/renderDefaultLocale.test.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from "contentful" 2 | import format from "../../support/format" 3 | import renderDefaultLocale from "../../../src/renderers/contentful/renderDefaultLocale" 4 | 5 | describe("renderSymbol()", () => { 6 | const locales: Locale[] = [ 7 | { 8 | name: "English (US)", 9 | fallbackCode: null, 10 | code: "en-US", 11 | default: true, 12 | sys: {} as Locale["sys"], 13 | }, 14 | { 15 | name: "Brazilian Portuguese", 16 | fallbackCode: "en-US", 17 | code: "pt-BR", 18 | default: false, 19 | sys: {} as Locale["sys"], 20 | }, 21 | ] 22 | 23 | it("works with a list of locales", () => { 24 | expect(format(renderDefaultLocale(locales))).toMatchInlineSnapshot( 25 | `"export type CONTENTFUL_DEFAULT_LOCALE_CODE = \\"en-US\\";"`, 26 | ) 27 | }) 28 | 29 | const localesWithNoDefault: Locale[] = [ 30 | { 31 | name: "English (US)", 32 | fallbackCode: null, 33 | code: "en-US", 34 | default: false, 35 | sys: {} as Locale["sys"], 36 | }, 37 | ] 38 | 39 | it("throws an error when there is no default", () => { 40 | expect(() => { 41 | renderDefaultLocale(localesWithNoDefault) 42 | }).toThrow() 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/renderers/render.test.ts: -------------------------------------------------------------------------------- 1 | import render from "../../src/renderers/render" 2 | import { ContentType, Sys, Locale } from "contentful" 3 | 4 | describe("render()", () => { 5 | const contentTypes: ContentType[] = [ 6 | { 7 | sys: { 8 | id: "myContentType", 9 | } as Sys, 10 | fields: [ 11 | { 12 | id: "arrayField", 13 | name: "Array field", 14 | required: true, 15 | validations: [{}], 16 | items: { 17 | type: "Symbol", 18 | validations: [ 19 | { 20 | in: ["one's", "of", "the", "above"], 21 | }, 22 | ], 23 | }, 24 | disabled: false, 25 | omitted: false, 26 | localized: false, 27 | type: "Array", 28 | }, 29 | ], 30 | description: "", 31 | displayField: "", 32 | name: "", 33 | toPlainObject: () => ({} as ContentType), 34 | }, 35 | ] 36 | 37 | const locales: Locale[] = [ 38 | { 39 | name: "English (US)", 40 | fallbackCode: null, 41 | code: "en-US", 42 | default: true, 43 | sys: {} as Locale["sys"], 44 | }, 45 | { 46 | name: "Brazilian Portuguese", 47 | fallbackCode: "en-US", 48 | code: "pt-BR", 49 | default: false, 50 | sys: {} as Locale["sys"], 51 | }, 52 | ] 53 | 54 | it("renders a given content type", async () => { 55 | expect(await render(contentTypes, locales)).toMatchInlineSnapshot(` 56 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 57 | 58 | import { Asset, Entry } from \\"contentful\\" 59 | 60 | export interface IMyContentTypeFields { 61 | /** Array field */ 62 | arrayField: (\\"one's\\" | \\"of\\" | \\"the\\" | \\"above\\")[] 63 | } 64 | 65 | export interface IMyContentType extends Entry { 66 | sys: { 67 | id: string 68 | type: string 69 | createdAt: string 70 | updatedAt: string 71 | locale: string 72 | contentType: { 73 | sys: { 74 | id: \\"myContentType\\" 75 | linkType: \\"ContentType\\" 76 | type: \\"Link\\" 77 | } 78 | } 79 | } 80 | } 81 | 82 | export type CONTENT_TYPE = \\"myContentType\\" 83 | 84 | export type IEntry = IMyContentType 85 | 86 | export type LOCALE_CODE = \\"en-US\\" | \\"pt-BR\\" 87 | 88 | export type CONTENTFUL_DEFAULT_LOCALE_CODE = \\"en-US\\" 89 | " 90 | `) 91 | }) 92 | 93 | it("renders a given content type (with RichText)", async () => { 94 | const contentTypes: ContentType[] = [ 95 | { 96 | sys: { 97 | id: "myContentType", 98 | } as Sys, 99 | fields: [ 100 | { 101 | id: "richTextField", 102 | name: "richText field", 103 | required: true, 104 | validations: [{}], 105 | items: { 106 | type: "Symbol", 107 | validations: [], 108 | }, 109 | disabled: false, 110 | omitted: false, 111 | localized: false, 112 | type: "RichText", 113 | }, 114 | ], 115 | description: "", 116 | displayField: "", 117 | name: "", 118 | toPlainObject: () => ({} as ContentType), 119 | }, 120 | ] 121 | expect(await render(contentTypes, locales)).toMatchInlineSnapshot(` 122 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 123 | 124 | import { Asset, Entry } from \\"contentful\\" 125 | import { Document } from \\"@contentful/rich-text-types\\" 126 | 127 | export interface IMyContentTypeFields { 128 | /** richText field */ 129 | richTextField: Document 130 | } 131 | 132 | export interface IMyContentType extends Entry { 133 | sys: { 134 | id: string 135 | type: string 136 | createdAt: string 137 | updatedAt: string 138 | locale: string 139 | contentType: { 140 | sys: { 141 | id: \\"myContentType\\" 142 | linkType: \\"ContentType\\" 143 | type: \\"Link\\" 144 | } 145 | } 146 | } 147 | } 148 | 149 | export type CONTENT_TYPE = \\"myContentType\\" 150 | 151 | export type IEntry = IMyContentType 152 | 153 | export type LOCALE_CODE = \\"en-US\\" | \\"pt-BR\\" 154 | 155 | export type CONTENTFUL_DEFAULT_LOCALE_CODE = \\"en-US\\" 156 | " 157 | `) 158 | }) 159 | 160 | it("renders a given localized content type", async () => { 161 | const contentTypes: ContentType[] = [ 162 | { 163 | sys: { 164 | id: "myContentType", 165 | } as Sys, 166 | fields: [ 167 | { 168 | id: "arrayField", 169 | name: "Array field", 170 | required: true, 171 | validations: [{}], 172 | items: { 173 | type: "Symbol", 174 | validations: [ 175 | { 176 | in: ["one's", "of", "the", "above"], 177 | }, 178 | ], 179 | }, 180 | disabled: false, 181 | omitted: false, 182 | localized: false, 183 | type: "Array", 184 | }, 185 | ], 186 | description: "", 187 | displayField: "", 188 | name: "", 189 | toPlainObject: () => ({} as ContentType), 190 | }, 191 | ] 192 | 193 | expect(await render(contentTypes, locales, { localization: true })).toMatchInlineSnapshot(` 194 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 195 | 196 | import { Entry } from \\"contentful\\" 197 | 198 | export interface IMyContentTypeFields { 199 | /** Array field */ 200 | arrayField: LocalizedField<(\\"one's\\" | \\"of\\" | \\"the\\" | \\"above\\")[]> 201 | } 202 | 203 | export interface IMyContentType extends Entry { 204 | sys: { 205 | id: string 206 | type: string 207 | createdAt: string 208 | updatedAt: string 209 | locale: string 210 | contentType: { 211 | sys: { 212 | id: \\"myContentType\\" 213 | linkType: \\"ContentType\\" 214 | type: \\"Link\\" 215 | } 216 | } 217 | } 218 | } 219 | 220 | export type CONTENT_TYPE = \\"myContentType\\" 221 | 222 | export type IEntry = IMyContentType 223 | 224 | export type LOCALE_CODE = \\"en-US\\" | \\"pt-BR\\" 225 | 226 | export type CONTENTFUL_DEFAULT_LOCALE_CODE = \\"en-US\\" 227 | 228 | export type LocalizedField = Partial> 229 | 230 | // We have to use our own localized version of Asset because of a bug in contentful https://github.com/contentful/contentful.js/issues/208 231 | export interface Asset { 232 | sys: Sys 233 | fields: { 234 | title: LocalizedField 235 | description: LocalizedField 236 | file: LocalizedField<{ 237 | url: string 238 | details: { 239 | size: number 240 | image?: { 241 | width: number 242 | height: number 243 | } 244 | } 245 | fileName: string 246 | contentType: string 247 | }> 248 | } 249 | toPlainObject(): object 250 | } 251 | " 252 | `) 253 | }) 254 | 255 | it("renders given a content type inside a namespace", async () => { 256 | expect(await render(contentTypes, locales, { namespace: "Codegen" })).toMatchInlineSnapshot(` 257 | "// THIS FILE IS AUTOMATICALLY GENERATED. DO NOT MODIFY IT. 258 | 259 | import { Asset, Entry } from \\"contentful\\" 260 | 261 | declare namespace Codegen { 262 | export interface IMyContentTypeFields { 263 | /** Array field */ 264 | arrayField: (\\"one's\\" | \\"of\\" | \\"the\\" | \\"above\\")[] 265 | } 266 | 267 | export interface IMyContentType extends Entry { 268 | sys: { 269 | id: string 270 | type: string 271 | createdAt: string 272 | updatedAt: string 273 | locale: string 274 | contentType: { 275 | sys: { 276 | id: \\"myContentType\\" 277 | linkType: \\"ContentType\\" 278 | type: \\"Link\\" 279 | } 280 | } 281 | } 282 | } 283 | 284 | export type CONTENT_TYPE = \\"myContentType\\" 285 | 286 | export type IEntry = IMyContentType 287 | 288 | export type LOCALE_CODE = \\"en-US\\" | \\"pt-BR\\" 289 | 290 | export type CONTENTFUL_DEFAULT_LOCALE_CODE = \\"en-US\\" 291 | } 292 | 293 | export as namespace Codegen 294 | export = Codegen 295 | " 296 | `) 297 | }) 298 | }) 299 | -------------------------------------------------------------------------------- /test/renderers/renderFieldsOnly.test.ts: -------------------------------------------------------------------------------- 1 | import renderFieldsOnly from "../../src/renderers/renderFieldsOnly" 2 | import { ContentType, Sys } from "contentful" 3 | 4 | describe("renderFieldsOnly()", () => { 5 | const contentTypes: ContentType[] = [ 6 | { 7 | sys: { 8 | id: "myContentType", 9 | } as Sys, 10 | fields: [ 11 | { 12 | id: "arrayField", 13 | name: "Array field", 14 | required: true, 15 | validations: [{}], 16 | items: { 17 | type: "Symbol", 18 | validations: [ 19 | { 20 | in: ["one", "of", "the", "above"], 21 | }, 22 | ], 23 | }, 24 | disabled: false, 25 | omitted: false, 26 | localized: false, 27 | type: "Array", 28 | }, 29 | ], 30 | description: "", 31 | displayField: "", 32 | name: "", 33 | toPlainObject: () => ({} as ContentType), 34 | }, 35 | ] 36 | 37 | it("renders a given content type", async () => { 38 | expect(await renderFieldsOnly(contentTypes)).toMatchInlineSnapshot(` 39 | "export interface IMyContentType { 40 | fields: { 41 | /** Array field */ 42 | arrayField: (\\"one\\" | \\"of\\" | \\"the\\" | \\"above\\")[] 43 | } 44 | [otherKeys: string]: any 45 | } 46 | " 47 | `) 48 | }) 49 | 50 | it("renders a given content type inside a namespace", async () => { 51 | expect(await renderFieldsOnly(contentTypes, { namespace: "Codegen" })).toMatchInlineSnapshot(` 52 | "declare namespace Codegen { 53 | export interface IMyContentType { 54 | fields: { 55 | /** Array field */ 56 | arrayField: (\\"one\\" | \\"of\\" | \\"the\\" | \\"above\\")[] 57 | } 58 | [otherKeys: string]: any 59 | } 60 | } 61 | 62 | export as namespace Codegen 63 | export = Codegen 64 | " 65 | `) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /test/renderers/typescript/renderArrayOf.test.ts: -------------------------------------------------------------------------------- 1 | import renderArrayOf from "../../../src/renderers/typescript/renderArrayOf" 2 | 3 | describe("renderArrayOf()", () => { 4 | it("renders array types safely", () => { 5 | expect(renderArrayOf("thing")).toMatchInlineSnapshot(`"(thing)[]"`) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /test/renderers/typescript/renderInterface.test.ts: -------------------------------------------------------------------------------- 1 | import renderInterface from "../../../src/renderers/typescript/renderInterface" 2 | import format from "../../support/format" 3 | 4 | describe("renderInterface()", () => { 5 | it("works", () => { 6 | expect(format(renderInterface({ name: "IFoo", fields: "field: string" }))) 7 | .toMatchInlineSnapshot(` 8 | "export interface IFoo { 9 | field: string; 10 | }" 11 | `) 12 | }) 13 | 14 | it("adds comments to interfaces", () => { 15 | expect( 16 | format( 17 | renderInterface({ 18 | name: "IFoo", 19 | fields: "field: string", 20 | description: "Example interface", 21 | }), 22 | ), 23 | ).toMatchInlineSnapshot(` 24 | "/** Example interface */ 25 | export interface IFoo { 26 | field: string; 27 | }" 28 | `) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /test/renderers/typescript/renderInterfaceProperty.test.ts: -------------------------------------------------------------------------------- 1 | import renderInterfaceProperty from "../../../src/renderers/typescript/renderInterfaceProperty" 2 | 3 | describe("renderInterfaceProperty()", () => { 4 | it("works with unrequired properties", () => { 5 | expect(renderInterfaceProperty("property", "type", false, false).trim()).toMatchInlineSnapshot( 6 | `"property?: type | undefined;"`, 7 | ) 8 | }) 9 | 10 | it("works with required properties", () => { 11 | expect(renderInterfaceProperty("property", "type", true, false).trim()).toMatchInlineSnapshot( 12 | `"property: type;"`, 13 | ) 14 | }) 15 | 16 | it("adds descriptions", () => { 17 | expect(renderInterfaceProperty("property", "type", false, false, "Description").trim()) 18 | .toMatchInlineSnapshot(` 19 | "/** Description */ 20 | property?: type | undefined;" 21 | `) 22 | }) 23 | 24 | it("supports localized fields", () => { 25 | expect(renderInterfaceProperty("property", "type", false, true).trim()).toMatchInlineSnapshot( 26 | `"property?: LocalizedField | undefined;"`, 27 | ) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /test/renderers/typescript/renderUnion.test.ts: -------------------------------------------------------------------------------- 1 | import renderUnion, { renderUnionValues } from "../../../src/renderers/typescript/renderUnion" 2 | import format from "../../support/format" 3 | 4 | describe("renderUnion()", () => { 5 | it("renders a union", () => { 6 | expect(format(renderUnion("name", ["1", "2", "3"]))).toMatchInlineSnapshot( 7 | `"export type name = 1 | 2 | 3;"`, 8 | ) 9 | }) 10 | }) 11 | 12 | describe("renderUnionValues()", () => { 13 | it("renders a union", () => { 14 | expect(renderUnionValues(["1", "2", "3"])).toMatchInlineSnapshot(`"1 | 2 | 3"`) 15 | }) 16 | 17 | it("handles empty unions", () => { 18 | expect(renderUnionValues([])).toMatchInlineSnapshot(`"never"`) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/renderers/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { escapeSingleQuotes } from "../../src/renderers/utils" 2 | 3 | describe("escapeSingleQuotes()", () => { 4 | const testStrings = [ 5 | "one's", 6 | "no quotes", 7 | "the quote's pie", 8 | "Tom's pie is better than quote's pie", 9 | "Alot of quotes after '''''", 10 | "''''' Alot of quotes before", 11 | ] 12 | 13 | const escapedStrings = [ 14 | "one\\'s", 15 | "no quotes", 16 | "the quote\\'s pie", 17 | "Tom\\'s pie is better than quote\\'s pie", 18 | "Alot of quotes after \\'\\'\\'\\'\\'", 19 | "\\'\\'\\'\\'\\' Alot of quotes before", 20 | ] 21 | 22 | it("escapes all the single quotes", () => { 23 | expect(testStrings.map(escapeSingleQuotes)).toStrictEqual(escapedStrings) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/support/format.ts: -------------------------------------------------------------------------------- 1 | import { format as prettierFormat } from "prettier" 2 | 3 | export default function format(source: string): string { 4 | return prettierFormat(source, { parser: "babel" }).trim() 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "noUnusedLocals": true, 9 | "sourceMap": true, 10 | "declaration": true, 11 | "allowSyntheticDefaultImports": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "declarationDir": "dist/types", 15 | "outDir": "dist/lib", 16 | "typeRoots": ["node_modules/@types"] 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ] 6 | } --------------------------------------------------------------------------------