├── src ├── installer │ ├── templates │ │ ├── .gitignore │ │ ├── index.ts │ │ ├── ext.ts │ │ ├── utils.ts │ │ └── base.ts │ ├── index.ts │ └── index.test.ts ├── index.ts ├── defaults.ts ├── main.ts ├── schema-downloader │ ├── fixtures │ │ ├── contentful-schema.json │ │ └── contentful-schema-from-export.json │ ├── index.test.ts │ └── index.ts ├── plugin.ts ├── fixtures │ ├── menu.json │ └── contentful-schema.json ├── generator.ts ├── integration.test.ts └── content-type-writer.ts ├── bin └── contentful-ts-generator ├── screenshot.png ├── types └── contentful-management.d.ts ├── babel.config.js ├── .gitignore ├── tsconfig.test.json ├── .travis.yml ├── tsconfig.dist.json ├── .nycrc ├── tsconfig.json ├── tslint.json ├── .github └── workflows │ └── ci.yml ├── package.json └── README.md /src/installer/templates/.gitignore: -------------------------------------------------------------------------------- 1 | generated -------------------------------------------------------------------------------- /bin/contentful-ts-generator: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | require('../dist/main') 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/watermarkchurch/contentful-ts-generator/HEAD/screenshot.png -------------------------------------------------------------------------------- /types/contentful-management.d.ts: -------------------------------------------------------------------------------- 1 | 2 | declare module 'contentful-management' { 3 | export function createClient(options: any): any 4 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript' 5 | ], 6 | }; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | 2 | export {ContentfulTSGenerator, IGeneratorOptions} from './generator' 3 | export {ContentfulTSGeneratorPlugin, IPluginOptions} from './plugin' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | **/src/**/*.js 5 | **/src/**/*.map 6 | **/src/**/*.d.ts 7 | 8 | coverage 9 | .nyc_output 10 | tmp 11 | .nvmrc 12 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | }, 5 | "exclude": [ 6 | "**/tmp/**/*.ts", 7 | "**/templates/**/*.ts" 8 | ] 9 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | before_script: 5 | - yarn run lerna bootstrap 6 | script: 7 | - yarn run test 8 | - yarn run coveralls 9 | - yarn run lint -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "exclude": [ 7 | "**/*.test.ts", 8 | "**/tmp/**/*.ts", 9 | "**/templates/**/*.ts" 10 | ] 11 | } -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*.js" 4 | ], 5 | "exclude": [ 6 | "**/*.ts", 7 | "**/*.test.js" 8 | ], 9 | "extension": [ 10 | ".js" 11 | ], 12 | "reporter": [ 13 | "text" 14 | ], 15 | "sourceMap": true, 16 | "instrument": true, 17 | "all": true 18 | } -------------------------------------------------------------------------------- /src/installer/templates/index.ts: -------------------------------------------------------------------------------- 1 | import { IEntry, ILink } from './base' 2 | import { TypeDirectory } from './generated' 3 | 4 | export * from './base' 5 | export {wrap} from './generated' 6 | 7 | export type KnownContentType = keyof TypeDirectory 8 | 9 | require('./ext') 10 | // include this to extend the generated objects 11 | // require('./ext/menu') 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { "*": ["types/*"] }, 5 | "declaration": true, 6 | "noImplicitAny": true, 7 | "strict": true, 8 | "sourceMap": true, 9 | "esModuleInterop": true, 10 | "lib": [ 11 | "es2015" 12 | ], 13 | "types": [ 14 | "node", 15 | "jest" 16 | ] 17 | }, 18 | "include": [ 19 | "src/**/*.ts" 20 | ] 21 | } -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | 3 | let outputDir: string 4 | if (fs.existsSync('app/assets/javascripts')) { 5 | outputDir = 'app/assets/javascripts/lib/contentful' 6 | } else { 7 | outputDir = 'lib/contentful' 8 | } 9 | 10 | let schemaFile: string 11 | if (fs.existsSync('db') && fs.statSync('db').isDirectory()) { 12 | schemaFile = 'db/contentful-schema.json' 13 | } else { 14 | schemaFile = 'contentful-schema.json' 15 | } 16 | 17 | const defaults = { 18 | managementToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN, 19 | space: process.env.CONTENTFUL_SPACE_ID, 20 | environment: process.env.CONTENTFUL_ENVIRONMENT || 'master', 21 | outputDir, 22 | schemaFile, 23 | } 24 | 25 | export default defaults 26 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-eslint-rules", 5 | "tslint-eslint-rules-recommended" 6 | ], 7 | "rules": { 8 | "semicolon": [true, "never"], 9 | "quotemark": [true, "single"], 10 | "triple-equals": false, 11 | "no-var-requires": false, 12 | "object-literal-sort-keys": false, 13 | "no-floating-promises": true, 14 | "promise-function-async": true, 15 | "await-promise": true, 16 | "no-return-await": true, 17 | "no-inferred-empty-object-type": true, 18 | "match-default-export-name": true, 19 | "no-unused-variable": false 20 | }, 21 | "linterOptions": { 22 | "exclude": [ 23 | "**/*.js", 24 | "**/*.d.ts", 25 | "tmp/**/*.ts" 26 | ] 27 | } 28 | } -------------------------------------------------------------------------------- /src/installer/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import globby from 'globby' 3 | import * as path from 'path' 4 | import defaults from '../defaults' 5 | 6 | interface IInstallerOptions { 7 | outputDir: string 8 | 9 | logger: { debug: Console['debug'] } 10 | } 11 | 12 | const templateDir = path.join(__dirname, 'templates') 13 | 14 | export class Installer { 15 | private readonly options: Readonly 16 | 17 | constructor(options?: Partial) { 18 | const opts = Object.assign({ 19 | ...defaults, 20 | logger: console, 21 | }, options) 22 | 23 | this.options = opts as IInstallerOptions 24 | } 25 | 26 | public install = async () => { 27 | if (await fs.pathExists(path.join(this.options.outputDir, 'index.ts'))) { 28 | // already installed - don't reinstall 29 | return 30 | } 31 | 32 | const files = await globby(path.join(templateDir, '**/*'), { dot: true }) 33 | await Promise.all(files.map(async (file) => 34 | this.installFile(file), 35 | )) 36 | } 37 | 38 | public installFile = async (file: string) => { 39 | const relPath = path.relative(templateDir, file) 40 | const outPath = path.join(this.options.outputDir, relPath) 41 | 42 | if (await fs.pathExists(outPath)) { 43 | return 44 | } 45 | 46 | this.options.logger.debug('install file', relPath, 'to', outPath) 47 | await fs.copy(file, outPath) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/installer/templates/ext.ts: -------------------------------------------------------------------------------- 1 | import { ContentfulClientApi, Entry as ContentfulEntry } from 'contentful' 2 | import { Entry, IAsset, IEntry, JsonObject, Resolved } from './base' 3 | 4 | // Comment this out if you do not have the `contentful` NPM module installed. 5 | declare module 'contentful' { 6 | // tslint:disable:interface-name 7 | export interface Entry { 8 | toPlainObject(): IEntry 9 | } 10 | 11 | export interface Asset { 12 | toPlainObject(): IAsset 13 | } 14 | 15 | export interface ContentfulClientApi { 16 | /** 17 | * Get an entry, casting to its content type. 18 | * 19 | * Since the Contentful API by default resolves to one layer, the resulting 20 | * type is a Resolved entry. 21 | */ 22 | getEntry>(id: string, query?: any): Promise> 23 | getEntry(id: string, query?: any): Promise>> 24 | } 25 | } 26 | 27 | declare module './base' { 28 | export interface Entry { 29 | /** 30 | * Resolves this entry to the specified depth (less than 10), and returns the 31 | * raw object. 32 | * @param n The depth to resolve to. 33 | * @param client The client to use. 34 | */ 35 | resolve(n: number, client: ContentfulClientApi, query?: any): Promise>> 36 | } 37 | } 38 | 39 | Entry.prototype.resolve = async function(n, client, query?: any) { 40 | const id = this.sys.id 41 | const entry = await client.getEntry(id, Object.assign({}, query, { include: n })) 42 | const pojo = (entry as ContentfulEntry).toPlainObject() 43 | 44 | Object.assign(this, pojo) 45 | return pojo 46 | } 47 | -------------------------------------------------------------------------------- /src/installer/templates/utils.ts: -------------------------------------------------------------------------------- 1 | import { IEntry, isEntry, isLink, JsonObject, Resolved } from '.' 2 | 3 | /** 4 | * Returns a boolean indicating whether the given entry is resolved to a certain 5 | * depth. Typescript can understand the result of this within an if or switch 6 | * statement. 7 | * 8 | * @param entry The entry whose fields should be checked for links 9 | * @param depth how far down the tree to expect that the entry was resolved. 10 | * @returns a boolean indicating that the entry is a Resolved entry. 11 | */ 12 | export function isResolved( 13 | entry: IEntry, 14 | depth: number = 1, 15 | ): entry is Resolved> { 16 | if (depth < 1) { throw new Error(`Depth cannot be less than 1 (was ${depth})`) } 17 | 18 | return Object.keys(entry.fields).every((field) => { 19 | const val = entry.fields[field] 20 | 21 | if (Array.isArray(val)) { 22 | return val.every(check) 23 | } else { 24 | return check(val) 25 | } 26 | }) 27 | 28 | function check(val: any): boolean { 29 | if (isLink(val)) { 30 | return false 31 | } 32 | if (depth > 1 && isEntry(val)) { 33 | return isResolved(val, depth - 1) 34 | } 35 | return true 36 | } 37 | } 38 | 39 | /** 40 | * Expects that an entry has been resolved to at least a depth of 1, 41 | * throwing an error if not. 42 | * 43 | * @param entry The entry whose fields should be checked for links 44 | * @returns the same entry object, declaring it as resolved. 45 | */ 46 | export function expectResolved( 47 | entry: IEntry, 48 | depth: number = 1, 49 | ): Resolved> { 50 | if (isResolved(entry, depth)) { 51 | return entry 52 | } 53 | throw new Error(`${entry.sys.contentType.sys.id} ${entry.sys.id} was not fully resolved`) 54 | } 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.x, 16.x, 18.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - run: yarn run test 28 | 29 | - name: Coveralls Parallel 30 | uses: coverallsapp/github-action@master 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | flag-name: node-${{ matrix.node-version }} 34 | parallel: true 35 | 36 | lint: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Use Node.js 18.x 41 | uses: actions/setup-node@v3 42 | with: 43 | node-version: 18.x 44 | - name: Install dependencies 45 | run: yarn install --frozen-lockfile 46 | 47 | - run: yarn run lint 48 | 49 | build: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v3 53 | - name: Use Node.js 18.x 54 | uses: actions/setup-node@v3 55 | with: 56 | node-version: 18.x 57 | - name: Install dependencies 58 | run: yarn install --frozen-lockfile 59 | 60 | - run: yarn run build 61 | 62 | finish: 63 | needs: [test, lint, build] 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Coveralls Finished 67 | uses: coverallsapp/github-action@master 68 | with: 69 | github-token: ${{ secrets.GITHUB_TOKEN }} 70 | parallel-finished: true 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contentful-ts-generator", 3 | "version": "0.2.12", 4 | "description": "A CLI & webpack plugin for automatically generating Typescript code based on the content types in your Contentful space.", 5 | "main": "dist/index.js", 6 | "bin": "bin/contentful-ts-generator", 7 | "files": [ 8 | "dist/**/*", 9 | "bin/contentful-ts-generator", 10 | "README.md" 11 | ], 12 | "scripts": { 13 | "lint": "tslint --project tsconfig.json 'packages/*/src/**/*.ts'", 14 | "fix": "tslint --project tsconfig.json 'packages/*/src/**/*.ts' --fix", 15 | "test": "jest --coverage", 16 | "test-watch": "jest --watch", 17 | "build": "tsc --project tsconfig.dist.json && rsync -avR src/./installer/templates dist", 18 | "coveralls": "nyc report --reporter=text-lcov | coveralls", 19 | "clean": "rm -rf dist coverage .nyc_output; tsc --build --clean; rm -rf src/**/tmp dist; true", 20 | "prepack": "yarn run build" 21 | }, 22 | "keywords": [ 23 | "contentful", 24 | "typescript", 25 | "codegen" 26 | ], 27 | "homepage": "https://github.com/watermarkchurch/ts-generators/tree/master/packages/contentful-ts-generator", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/watermarkchurch/ts-generators.git" 31 | }, 32 | "author": "Gordon Burgett (gordon@gordonburgett.net)", 33 | "license": "MIT", 34 | "devDependencies": { 35 | "@types/fs-extra": "^5.0.5", 36 | "@types/globby": "^9.1.0", 37 | "@types/inflection": "^1.5.28", 38 | "@types/jest": "^29.0.2", 39 | "@types/nock": "^9.3.1", 40 | "@types/node": "^12.12.6", 41 | "@types/tmp": "0.0.34", 42 | "@types/webpack": "^4.4.25", 43 | "@types/yargs": "^13.0.3", 44 | "contentful": "^7.4.3", 45 | "coveralls": "^3.1.1", 46 | "jest": "^29.0.3", 47 | "nock": "^10.0.6", 48 | "tslint": "^5.12.1", 49 | "tslint-eslint-rules": "^5.4.0", 50 | "tslint-eslint-rules-recommended": "^1.2.0", 51 | "typescript": "^4.8.3" 52 | }, 53 | "dependencies": { 54 | "@babel/core": "^7.19.1", 55 | "@babel/preset-env": "^7.19.1", 56 | "@babel/preset-typescript": "^7.18.6", 57 | "async-toolbox": "^0.4.2", 58 | "babel-jest": "^29.0.3", 59 | "chalk": "^2.4.2", 60 | "contentful-management": ">=5.7.0", 61 | "fs-extra": "^7.0.1", 62 | "globby": "^9.1.0", 63 | "inflection": "^1.12.0", 64 | "limiter": "^1.1.4", 65 | "tmp": "0.1.0", 66 | "ts-morph": ">=1.3.0", 67 | "yargs": "^13.3.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/installer/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import * as path from 'path' 3 | import * as tmp from 'tmp' 4 | import { promisify } from 'util' 5 | 6 | import { Installer } from './index' 7 | 8 | const templateDir = path.join(__dirname, 'templates') 9 | 10 | describe('installer', () => { 11 | let tmpDir: string 12 | 13 | beforeEach(async () => { 14 | tmpDir = await (promisify((cb) => tmp.dir(cb))()) 15 | 16 | await fs.remove(tmpDir) 17 | await fs.mkdirp(tmpDir) 18 | }) 19 | 20 | it('installs templates to empty directory', async () => { 21 | 22 | 23 | const installer = new Installer({ 24 | outputDir: tmpDir, 25 | }) 26 | 27 | await installer.install() 28 | 29 | const templateFiles = ['base.ts', 'index.ts', 'utils.ts', '.gitignore'] 30 | 31 | await Promise.all(templateFiles.map(async (file) => { 32 | const fullPath = path.join(tmpDir, file) 33 | expect(await fs.pathExists(fullPath)).toBeTruthy() 34 | })) 35 | await Promise.all(templateFiles.map(async (file) => { 36 | const contents = await fs.readFile(path.join(tmpDir, file)) 37 | const expected = await fs.readFile(path.join(templateDir, file)) 38 | expect(contents.toString()).toEqual(expected.toString()) 39 | })) 40 | }) 41 | 42 | it('does not overwrite existing files in the directory', async () => { 43 | 44 | 45 | const installer = new Installer({ 46 | outputDir: tmpDir, 47 | }) 48 | 49 | await fs.writeFile(path.join(tmpDir, 'base.ts'), '// test test test') 50 | 51 | await installer.install() 52 | 53 | const templateFiles = ['base.ts', 'index.ts', 'utils.ts'] 54 | 55 | await Promise.all(templateFiles.map(async (file) => { 56 | const fullPath = path.join(tmpDir, file) 57 | expect(await fs.pathExists(fullPath)).toBeTruthy() 58 | })) 59 | 60 | const contents = await fs.readFile(path.join(tmpDir, 'base.ts')) 61 | expect(contents.toString()).toEqual('// test test test') 62 | 63 | }) 64 | 65 | it('does not install any files if index exists', async () => { 66 | 67 | 68 | const installer = new Installer({ 69 | outputDir: tmpDir, 70 | }) 71 | 72 | await fs.writeFile(path.join(tmpDir, 'index.ts'), '// test test test') 73 | 74 | await installer.install() 75 | 76 | const templateFiles = ['base.ts', 'utils.ts'] 77 | 78 | await Promise.all(templateFiles.map(async (file) => { 79 | const fullPath = path.join(tmpDir, file) 80 | expect(await fs.pathExists(fullPath)).toBeFalsy() 81 | })) 82 | 83 | const contents = await fs.readFile(path.join(tmpDir, 'index.ts')) 84 | expect(contents.toString()).toEqual('// test test test') 85 | }) 86 | 87 | }) -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import * as fs from 'fs-extra' 3 | import * as path from 'path' 4 | import * as yargs from 'yargs' 5 | 6 | import defaults from './defaults' 7 | import { ContentfulTSGenerator } from './generator' 8 | import { Installer } from './installer' 9 | import { SchemaDownloader } from './schema-downloader' 10 | 11 | interface IArgv { 12 | /** The schema file to load for code generation */ 13 | file: string, 14 | /** The output directory in which to write the code */ 15 | out: string 16 | /** Whether to download */ 17 | download: boolean 18 | managementToken: string, 19 | space: string, 20 | environment: string 21 | verbose?: boolean 22 | } 23 | 24 | interface ILogger { 25 | log: Console['log'], 26 | error: Console['error'], 27 | debug: Console['debug'], 28 | } 29 | 30 | yargs 31 | .option('file', { 32 | alias: 'f', 33 | describe: 'The location on disk of the schema file.', 34 | }) 35 | .option('out', { 36 | alias: 'o', 37 | describe: 'Where to place the generated code.', 38 | }) 39 | .option('download', { 40 | boolean: true, 41 | alias: 'd', 42 | describe: 'Whether to download the schema file from the Contentful space first', 43 | }) 44 | .option('managementToken', { 45 | alias: 'm', 46 | describe: 'The Contentful management token. Defaults to the env var CONTENTFUL_MANAGEMENT_TOKEN', 47 | }) 48 | .option('space', { 49 | alias: 's', 50 | describe: 'The Contentful space ID. Defaults to the env var CONTENTFUL_SPACE_ID', 51 | }) 52 | .option('environment', { 53 | alias: 'e', 54 | describe: 'The Contentful environment. Defaults to the env var CONTENTFUL_ENVIRONMENT or \'master\'', 55 | }) 56 | .option('verbose', { 57 | boolean: true, 58 | alias: 'v', 59 | describe: 'Enable verbose logging', 60 | }) 61 | 62 | // tslint:disable-next-line:no-shadowed-variable 63 | async function Run(args: IArgv, logger: ILogger = console) { 64 | if (args.download) { 65 | const downloader = new SchemaDownloader({ 66 | ...args, 67 | directory: path.dirname(args.file), 68 | filename: path.basename(args.file), 69 | logger, 70 | }) 71 | 72 | await downloader.downloadSchema() 73 | } 74 | 75 | const installer = new Installer({ 76 | outputDir: args.out, 77 | logger, 78 | }) 79 | 80 | const generator = new ContentfulTSGenerator({ 81 | outputDir: path.join(args.out, 'generated'), 82 | schemaFile: args.file, 83 | }) 84 | 85 | await Promise.all([ 86 | installer.install(), 87 | generator.generate(), 88 | ]) 89 | } 90 | 91 | const args = Object.assign, Partial>( 92 | { 93 | ...defaults, 94 | out: defaults.outputDir, 95 | file: defaults.schemaFile, 96 | }, 97 | yargs.argv as Partial) 98 | 99 | if (typeof (args.download) == 'undefined') { 100 | if (args.managementToken && args.space && args.environment) { 101 | args.download = true 102 | } 103 | } else if (typeof (args.download) == 'string') { 104 | args.download = args.download == 'true' 105 | } 106 | 107 | // tslint:disable:no-console 108 | 109 | const logger: ILogger = { 110 | error: console.error, 111 | log: console.log, 112 | debug: () => undefined, 113 | } 114 | 115 | if (args.verbose) { 116 | logger.debug = (...msg: any[]) => { 117 | console.debug(chalk.gray(...msg)) 118 | } 119 | } 120 | 121 | Run(args as IArgv, logger) 122 | .catch((err) => 123 | console.error(chalk.red('An unexpected error occurred!'), err)) 124 | -------------------------------------------------------------------------------- /src/schema-downloader/fixtures/contentful-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "contentTypes": [ 3 | { 4 | "sys": { 5 | "id": "menu", 6 | "type": "ContentType" 7 | }, 8 | "displayField": "name", 9 | "name": "Menu", 10 | "description": "A Menu contains...", 11 | "fields": [ 12 | { 13 | "id": "name", 14 | "name": "Name", 15 | "type": "Symbol", 16 | "localized": false, 17 | "required": false, 18 | "validations": [], 19 | "disabled": false, 20 | "omitted": false 21 | }, 22 | { 23 | "id": "items", 24 | "name": "Items", 25 | "type": "Array", 26 | "localized": false, 27 | "required": false, 28 | "validations": [], 29 | "disabled": false, 30 | "omitted": false, 31 | "items": { 32 | "type": "Link", 33 | "validations": [ 34 | { 35 | "linkContentType": [ 36 | "dropdownMenu", 37 | "menuButton" 38 | ], 39 | "message": "The Menu groups must contain only sub-Menus or MenuButtons" 40 | } 41 | ], 42 | "linkType": "Entry" 43 | } 44 | } 45 | ] 46 | }, 47 | { 48 | "sys": { 49 | "id": "testimonial", 50 | "type": "ContentType" 51 | }, 52 | "displayField": "name", 53 | "name": "Testimonial", 54 | "description": "A Testimonial contains a user's photo...", 55 | "fields": [ 56 | { 57 | "id": "name", 58 | "name": "Name", 59 | "type": "Symbol", 60 | "localized": false, 61 | "required": true, 62 | "validations": [], 63 | "disabled": false, 64 | "omitted": false 65 | }, 66 | { 67 | "id": "photo", 68 | "name": "Photo", 69 | "type": "Link", 70 | "localized": false, 71 | "required": true, 72 | "validations": [ 73 | { 74 | "linkMimetypeGroup": [ 75 | "image" 76 | ] 77 | } 78 | ], 79 | "disabled": false, 80 | "omitted": false, 81 | "linkType": "Asset" 82 | } 83 | ] 84 | } 85 | ], 86 | "editorInterfaces": [ 87 | { 88 | "sys": { 89 | "id": "default", 90 | "type": "EditorInterface", 91 | "contentType": { 92 | "sys": { 93 | "id": "menu", 94 | "type": "Link", 95 | "linkType": "ContentType" 96 | } 97 | } 98 | }, 99 | "controls": [ 100 | { 101 | "fieldId": "items", 102 | "widgetId": "entryLinksEditor" 103 | }, 104 | { 105 | "fieldId": "name", 106 | "settings": { 107 | "helpText": "This is the name" 108 | }, 109 | "widgetId": "singleLine" 110 | } 111 | ] 112 | }, 113 | { 114 | "sys": { 115 | "id": "default", 116 | "type": "EditorInterface", 117 | "contentType": { 118 | "sys": { 119 | "id": "testimonial", 120 | "type": "Link", 121 | "linkType": "ContentType" 122 | } 123 | } 124 | }, 125 | "controls": [ 126 | { 127 | "fieldId": "name", 128 | "widgetId": "singleLine" 129 | }, 130 | { 131 | "fieldId": "photo", 132 | "widgetId": "assetLinkEditor" 133 | } 134 | ] 135 | } 136 | ] 137 | } 138 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import * as path from 'path' 3 | import { Compiler } from 'webpack' 4 | 5 | import { callbackify } from 'util' 6 | import defaults from './defaults' 7 | import { ContentfulTSGenerator, IGeneratorOptions } from './generator' 8 | import { Installer } from './installer' 9 | import { SchemaDownloader } from './schema-downloader' 10 | 11 | export interface IPluginOptions extends IGeneratorOptions { 12 | /** The location on disk of the schema file. */ 13 | schemaFile: string 14 | /** Where to place the generated code. */ 15 | outputDir: string 16 | /** Whether to download the schema file from the Contentful space first */ 17 | downloadSchema: boolean | undefined 18 | /** The Contentful space ID. Defaults to the env var CONTENTFUL_SPACE_ID */ 19 | space?: string 20 | /** The Contentful environment. Defaults to the env var CONTENTFUL_ENVIRONMENT or \'master\' */ 21 | environment?: string 22 | /** The Contentful management token. Defaults to the env var CONTENTFUL_MANAGEMENT_TOKEN */ 23 | managementToken?: string 24 | 25 | logger: { 26 | log: Console['log'], 27 | debug: Console['debug'], 28 | } 29 | } 30 | 31 | export class ContentfulTSGeneratorPlugin { 32 | private readonly options: Readonly 33 | 34 | private readonly installer: Installer 35 | private readonly generator: ContentfulTSGenerator 36 | 37 | constructor(options?: Partial) { 38 | const opts = Object.assign({ 39 | ...defaults, 40 | logger: { 41 | debug: () => null, 42 | // tslint:disable-next-line:no-console 43 | log: console.error, 44 | }, 45 | }, options) 46 | 47 | if (opts.downloadSchema) { 48 | if (!opts.managementToken) { 49 | throw new Error('Management token must be provided in order to download schema') 50 | } 51 | if (!opts.space) { 52 | throw new Error('Space ID must be provided in order to download schema') 53 | } 54 | if (!opts.environment) { 55 | throw new Error('Environment must be provided in order to download schema') 56 | } 57 | } 58 | 59 | this.options = opts as IPluginOptions 60 | this.installer = new Installer({ 61 | outputDir: this.options.outputDir, 62 | }) 63 | this.generator = new ContentfulTSGenerator({ 64 | schemaFile: this.options.schemaFile, 65 | outputDir: path.join(this.options.outputDir, 'generated'), 66 | }) 67 | } 68 | 69 | public apply = (compiler: Compiler) => { 70 | const self = this 71 | if (compiler.hooks) { 72 | compiler.hooks.run.tapPromise('ContentfulTSGenerator', async () => { 73 | await self.compile() 74 | }) 75 | } else { 76 | // webpack v2 77 | compiler.plugin('run', (compilation, callback) => { 78 | callbackify(() => this.compile())(callback) 79 | }) 80 | } 81 | } 82 | 83 | public compile = async () => { 84 | const options = this.options 85 | const indexFileName = path.join(path.resolve(options.outputDir), 'generated', 'index.ts') 86 | 87 | if (this.options.downloadSchema) { 88 | await this.downloader().downloadSchema() 89 | } else if (await fs.pathExists(indexFileName)) { 90 | const [i, s] = await Promise.all([ 91 | fs.statSync(indexFileName), 92 | fs.statSync(options.schemaFile), 93 | ]) 94 | if (s.mtime < i.mtime) { 95 | this.options.logger.log(`${options.schemaFile} not modified, skipping generation`) 96 | return 97 | } 98 | } else if (typeof(this.options.downloadSchema) == 'undefined') { 99 | await this.downloader().downloadSchema() 100 | } 101 | 102 | await Promise.all([ 103 | this.installer.install(), 104 | this.generator.generate(), 105 | ]) 106 | } 107 | 108 | private downloader() { 109 | return new SchemaDownloader({ 110 | ...this.options, 111 | directory: path.dirname(this.options.schemaFile), 112 | filename: path.basename(this.options.schemaFile), 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/schema-downloader/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import nock from 'nock' 3 | import * as path from 'path' 4 | import * as tmp from 'tmp' 5 | 6 | import { SchemaDownloader } from './index' 7 | 8 | let contentTypes: any 9 | let editorInterfaces: any 10 | 11 | const opts = { 12 | directory: '/tmp/db', 13 | managementToken: 'test', 14 | space: 'testspace', 15 | } 16 | 17 | describe('SchemaDownloader', () => { 18 | beforeEach(async () => { 19 | const fixture = await fs.readFile(path.join(__dirname, 'fixtures/contentful-schema-from-export.json')) 20 | const schema = JSON.parse(fixture.toString()) 21 | contentTypes = schema.contentTypes 22 | editorInterfaces = schema.editorInterfaces 23 | 24 | nock('https://api.contentful.com') 25 | .get('/spaces/testspace') 26 | .reply(200, { 27 | name: 'Test Space', 28 | sys: { 29 | type: 'Space', 30 | id: 'testspace', 31 | version: 2, 32 | createdBy: { 33 | sys: { 34 | type: 'Link', 35 | linkType: 'User', 36 | id: 'xxxxx', 37 | }, 38 | }, 39 | createdAt: '2018-01-22T17:49:19Z', 40 | updatedBy: { 41 | sys: { 42 | type: 'Link', 43 | linkType: 'User', 44 | id: 'xxxx', 45 | }, 46 | }, 47 | updatedAt: '2018-05-30T10:22:40Z', 48 | }, 49 | }) 50 | 51 | nock('https://api.contentful.com') 52 | .get('/spaces/testspace/environments/master') 53 | .reply(200, { 54 | name: 'master', 55 | sys: { 56 | type: 'Environment', 57 | id: 'master', 58 | version: 1, 59 | space: { 60 | sys: { 61 | type: 'Link', 62 | linkType: 'Space', 63 | id: 'testspace', 64 | }, 65 | }, 66 | status: { 67 | sys: { 68 | type: 'Link', 69 | linkType: 'Status', 70 | id: 'ready', 71 | }, 72 | }, 73 | createdBy: { 74 | sys: { 75 | type: 'Link', 76 | linkType: 'User', 77 | id: 'xxxx', 78 | }, 79 | }, 80 | createdAt: '2018-01-22T17:49:19Z', 81 | updatedBy: { 82 | sys: { 83 | type: 'Link', 84 | linkType: 'User', 85 | id: 'xxxx', 86 | }, 87 | }, 88 | updatedAt: '2018-01-22T17:49:19Z', 89 | }, 90 | }) 91 | 92 | nock('https://api.contentful.com') 93 | .get('/spaces/testspace/environments/master/content_types?limit=1000') 94 | .reply(200, () => { 95 | return JSON.stringify({ 96 | sys: { type: 'Array' }, 97 | total: contentTypes.length, 98 | skip: 0, 99 | limit: 1, 100 | items: contentTypes, 101 | }) 102 | }) 103 | const interfaceRegexp = /content_types\/([^\/]+)\/editor_interface/ 104 | nock('https://api.contentful.com') 105 | .get((uri) => interfaceRegexp.test(uri)) 106 | .times(Infinity) 107 | .reply((uri) => { 108 | const id = interfaceRegexp.exec(uri)![1] 109 | const ei = editorInterfaces.find((i: any) => i.sys.contentType.sys.id == id) 110 | if (ei) { 111 | return [ 112 | 200, 113 | JSON.stringify(ei), 114 | ] 115 | } else { 116 | return [404] 117 | } 118 | }) 119 | }) 120 | 121 | it('creates the file in the appropriate directory', async () => { 122 | const tmpdir = tmp.dirSync() 123 | try { 124 | const instance = new SchemaDownloader({ 125 | ...opts, 126 | directory: tmpdir.name 127 | }) 128 | 129 | await instance.downloadSchema() 130 | 131 | expect(await fs.pathExists(path.join(tmpdir.name, `/contentful-schema.json`))).toBeTruthy() 132 | } finally { 133 | tmpdir.removeCallback() 134 | } 135 | }) 136 | 137 | it('writes file with proper formatting', async () => { 138 | const tmpdir = tmp.dirSync() 139 | try { 140 | const instance = new SchemaDownloader({ 141 | ...opts, 142 | directory: tmpdir.name 143 | }) 144 | 145 | await instance.downloadSchema() 146 | 147 | const contents = (await fs.readFile(path.join(tmpdir.name, 'contentful-schema.json'))).toString() 148 | const expected = (await fs.readFile(path.join(__dirname, 'fixtures/contentful-schema.json'))).toString() 149 | expect(contents).toEqual(expected) 150 | 151 | } finally { 152 | tmpdir.removeCallback() 153 | } 154 | }) 155 | 156 | }) 157 | -------------------------------------------------------------------------------- /src/installer/templates/base.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-classes-per-file 2 | // tslint:disable-next-line:interface-over-type-literal 3 | export type JsonObject = { [k: string]: any } 4 | 5 | export interface IEntry { 6 | sys: ISys<'Entry'>, 7 | fields: TFields 8 | } 9 | 10 | export class Entry implements IEntry { 11 | public readonly sys!: ISys<'Entry'> 12 | public readonly fields!: TFields 13 | 14 | protected constructor(entryOrId: IEntry | string, contentType?: string, fields?: TFields) { 15 | if (typeof entryOrId == 'string') { 16 | if (!fields) { 17 | throw new Error('No fields provided') 18 | } 19 | if (!contentType) { 20 | throw new Error('No contentType provided') 21 | } 22 | 23 | this.sys = { 24 | id: entryOrId, 25 | type: 'Entry', 26 | space: undefined, 27 | contentType: { 28 | sys: { 29 | type: 'Link', 30 | linkType: 'ContentType', 31 | id: contentType, 32 | }, 33 | }, 34 | } 35 | this.fields = fields 36 | } else { 37 | if (typeof entryOrId.sys == 'undefined') { 38 | throw new Error('Entry did not have a `sys`!') 39 | } 40 | if (typeof entryOrId.fields == 'undefined') { 41 | throw new Error('Entry did not have a `fields`!') 42 | } 43 | Object.assign(this, entryOrId) 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * Checks whether the given object is a Contentful entry 50 | * @param obj 51 | */ 52 | export function isEntry(obj: any): obj is IEntry { 53 | return obj && obj.sys && obj.sys.type === 'Entry' 54 | } 55 | 56 | interface IAssetFields { 57 | title?: string, 58 | description?: string, 59 | file: { 60 | url?: string, 61 | details?: { 62 | size?: number, 63 | }, 64 | fileName?: string, 65 | contentType?: string, 66 | }, 67 | } 68 | 69 | export interface IAsset { 70 | sys: ISys<'Asset'>, 71 | fields: IAssetFields 72 | } 73 | 74 | export class Asset implements IAsset { 75 | public readonly sys!: ISys<'Asset'> 76 | public readonly fields!: IAssetFields 77 | 78 | constructor(asset: IAsset) 79 | constructor(id: string, fields: IAssetFields) 80 | constructor(entryOrId: IAsset | string, fields?: IAssetFields) { 81 | if (typeof entryOrId == 'string') { 82 | if (!fields) { 83 | throw new Error('No fields provided') 84 | } 85 | 86 | this.sys = { 87 | id: entryOrId, 88 | type: 'Asset', 89 | contentType: undefined, 90 | } 91 | this.fields = fields 92 | } else { 93 | if (typeof entryOrId.sys == 'undefined') { 94 | throw new Error('Entry did not have a `sys`!') 95 | } 96 | if (typeof entryOrId.fields == 'undefined') { 97 | throw new Error('Entry did not have a `fields`!') 98 | } 99 | Object.assign(this, entryOrId) 100 | } 101 | } 102 | } 103 | 104 | /** 105 | * Checks whether the given object is a Contentful asset 106 | * @param obj 107 | */ 108 | export function isAsset(obj: any): obj is IAsset { 109 | return obj && obj.sys && obj.sys.type === 'Asset' 110 | } 111 | 112 | export interface ILink { 113 | sys: { 114 | type: 'Link', 115 | linkType: Type, 116 | id: string, 117 | }, 118 | } 119 | 120 | export interface ISys { 121 | space?: ILink<'Space'>, 122 | id: string, 123 | type: Type, 124 | createdAt?: string, 125 | updatedAt?: string, 126 | revision?: number, 127 | environment?: ILink<'Environment'>, 128 | contentType: Type extends 'Entry' ? ILink<'ContentType'> : undefined, 129 | locale?: string, 130 | } 131 | 132 | /** 133 | * Checks whether the given object is a Contentful link 134 | * @param obj 135 | */ 136 | export function isLink(obj: any): obj is ILink { 137 | return obj && obj.sys && obj.sys.type === 'Link' 138 | } 139 | 140 | /** 141 | * This complex type & associated helper allow us to mark an entry as 142 | * not including any links. The compiler will understand that even though 143 | * the entry type (i.e. 'IPage') contains links, a Resolved will have 144 | * resolved the links into the actual entries or assets. 145 | */ 146 | export type Resolved = 147 | TEntry extends IEntry ? 148 | // TEntry is an entry and we know the type of it's props 149 | IEntry<{ 150 | [P in keyof TProps]: ResolvedField>> 151 | }> 152 | : never 153 | 154 | type ResolvedField = 155 | TField extends Array ? 156 | // Array of entries - dive into the item type to remove links 157 | Array>> : 158 | TField 159 | -------------------------------------------------------------------------------- /src/schema-downloader/index.ts: -------------------------------------------------------------------------------- 1 | import { Limiter } from 'async-toolbox' 2 | import {createClient} from 'contentful-management' 3 | import * as fs from 'fs-extra' 4 | import * as path from 'path' 5 | 6 | interface IOptions { 7 | directory: string 8 | filename: string 9 | space: string 10 | environment: string 11 | managementToken: string 12 | 13 | logger: { debug: Console['debug'] } 14 | } 15 | 16 | export class SchemaDownloader { 17 | private readonly options: Readonly 18 | private readonly client: any 19 | private readonly semaphore: Limiter 20 | 21 | constructor(options?: Partial) { 22 | const opts: IOptions = Object.assign({ 23 | directory: '.', 24 | filename: 'contentful-schema.json', 25 | managementToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN, 26 | space: process.env.CONTENTFUL_SPACE_ID, 27 | environment: process.env.CONTENTFUL_ENVIRONMENT || 'master', 28 | logger: console, 29 | } as IOptions, options) 30 | 31 | if (!opts.managementToken) { 32 | throw new Error('No managementToken given!') 33 | } 34 | 35 | this.options = opts 36 | this.client = createClient({ 37 | accessToken: opts.managementToken, 38 | requestLogger: this.requestLogger, 39 | responseLogger: this.responseLogger, 40 | }) 41 | this.semaphore = new Limiter({ 42 | interval: 'second', 43 | tokensPerInterval: 4, 44 | }) 45 | } 46 | 47 | public async downloadSchema() { 48 | const { 49 | contentTypes, 50 | editorInterfaces, 51 | } = await this.getSchemaFromSpace() 52 | 53 | if (this.options.directory) { 54 | await fs.mkdirp(this.options.directory) 55 | } 56 | const file = path.join(this.options.directory || '.', this.options.filename) 57 | await fs.writeFile(file, JSON.stringify({ 58 | contentTypes, 59 | editorInterfaces, 60 | }, undefined, ' ')) 61 | 62 | // contentful-shell and the wcc-contentful gem both add a newline at the end of the file. 63 | await fs.appendFile(file, '\n') 64 | } 65 | 66 | private async getSchemaFromSpace() { 67 | const space = await this.semaphore.lock(() => 68 | this.client.getSpace(this.options.space)) 69 | const env = await this.semaphore.lock(() => 70 | space.getEnvironment(this.options.environment)) 71 | 72 | // By default, the Contentful API will limit results to 100 items. 73 | // To avoid truncated results for environments with >100 content types, 74 | // we set the limit to the maximum allowed by Contentful (1000). 75 | // See: https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters/order-with-multiple-parameters 76 | const contentTypesResp = await this.semaphore.lock(() => 77 | env.getContentTypes({ limit: 1000 })) 78 | 79 | const editorInterfaces = (await Promise.all( 80 | contentTypesResp.items.map((ct: any) => 81 | this.semaphore.lock(async () => 82 | sortControls( 83 | stripSys( 84 | (await ct.getEditorInterface()) 85 | .toPlainObject(), 86 | ), 87 | ), 88 | )), 89 | )).sort(byContentType) 90 | const contentTypes = contentTypesResp.items.map((ct: any) => 91 | stripSys(ct.toPlainObject())) 92 | .sort(byId) 93 | 94 | return { 95 | contentTypes, 96 | editorInterfaces, 97 | } 98 | } 99 | 100 | private requestLogger = (config: any) => { 101 | // console.log('req', config) 102 | } 103 | 104 | private responseLogger = (response: any) => { 105 | this.options.logger.debug(response.status, response.config.url) 106 | } 107 | } 108 | 109 | function stripSys(obj: any): any { 110 | return { 111 | ...obj, 112 | sys: { 113 | id: obj.sys.id, 114 | type: obj.sys.type, 115 | contentType: obj.sys.contentType, 116 | }, 117 | } 118 | } 119 | 120 | function sortControls(editorInterface: any) { 121 | return { 122 | sys: editorInterface.sys, 123 | controls: editorInterface.controls 124 | .sort(byFieldId) 125 | .map((c: any) => ({ 126 | fieldId: c.fieldId, 127 | settings: c.settings, 128 | widgetId: c.widgetId, 129 | })), 130 | } 131 | } 132 | 133 | function byId(a: { sys: { id: string } }, b: { sys: { id: string } }): number { 134 | return a.sys.id.localeCompare(b.sys.id) 135 | } 136 | 137 | function byContentType( 138 | a: {sys: {contentType: {sys: {id: string}}}}, 139 | b: {sys: {contentType: {sys: {id: string}}}}, 140 | ): number { 141 | return a.sys.contentType.sys.id.localeCompare(b.sys.contentType.sys.id) 142 | } 143 | 144 | function byFieldId( 145 | a: { fieldId: string }, 146 | b: { fieldId: string }, 147 | ): number { 148 | return a.fieldId.localeCompare(b.fieldId) 149 | } 150 | -------------------------------------------------------------------------------- /src/fixtures/menu.json: -------------------------------------------------------------------------------- 1 | { 2 | "sys": { 3 | "type": "Array" 4 | }, 5 | "total": 1, 6 | "skip": 0, 7 | "limit": 100, 8 | "items": [ 9 | { 10 | "sys": { 11 | "space": { 12 | "sys": { 13 | "type": "Link", 14 | "linkType": "Space", 15 | "id": "7yx6ovlj39n5" 16 | } 17 | }, 18 | "id": "20bohaVp20MyKSi0YCaw8s", 19 | "type": "Entry", 20 | "createdAt": "2018-04-19T16:12:15.540Z", 21 | "updatedAt": "2018-04-19T16:12:15.540Z", 22 | "environment": { 23 | "sys": { 24 | "id": "master", 25 | "type": "Link", 26 | "linkType": "Environment" 27 | } 28 | }, 29 | "revision": 1, 30 | "contentType": { 31 | "sys": { 32 | "type": "Link", 33 | "linkType": "ContentType", 34 | "id": "menu" 35 | } 36 | }, 37 | "locale": "en-US" 38 | }, 39 | "fields": { 40 | "name": "Side Hamburger", 41 | "items": [ 42 | { 43 | "sys": { 44 | "type": "Link", 45 | "linkType": "Entry", 46 | "id": "DJfq5Q1ZbayWmgYSGuCSa" 47 | } 48 | }, 49 | { 50 | "sys": { 51 | "type": "Link", 52 | "linkType": "Entry", 53 | "id": "4FZb5aqcFiUKASguCEIYei" 54 | } 55 | }, 56 | { 57 | "sys": { 58 | "type": "Link", 59 | "linkType": "Entry", 60 | "id": "2kwNEGo2kcoIWCOICASIqk" 61 | } 62 | } 63 | ] 64 | } 65 | } 66 | ], 67 | "includes": { 68 | "Entry": [ 69 | { 70 | "sys": { 71 | "space": { 72 | "sys": { 73 | "type": "Link", 74 | "linkType": "Space", 75 | "id": "7yx6ovlj39n5" 76 | } 77 | }, 78 | "id": "2kwNEGo2kcoIWCOICASIqk", 79 | "type": "Entry", 80 | "createdAt": "2018-04-19T15:37:29.876Z", 81 | "updatedAt": "2018-08-15T14:48:14.211Z", 82 | "environment": { 83 | "sys": { 84 | "id": "master", 85 | "type": "Link", 86 | "linkType": "Environment" 87 | } 88 | }, 89 | "revision": 5, 90 | "contentType": { 91 | "sys": { 92 | "type": "Link", 93 | "linkType": "ContentType", 94 | "id": "menuButton" 95 | } 96 | }, 97 | "locale": "en-US" 98 | }, 99 | "fields": { 100 | "text": "Details", 101 | "link": { 102 | "sys": { 103 | "type": "Link", 104 | "linkType": "Entry", 105 | "id": "4xNnFJ77egkSMEogE2yISa" 106 | } 107 | } 108 | } 109 | }, 110 | { 111 | "sys": { 112 | "space": { 113 | "sys": { 114 | "type": "Link", 115 | "linkType": "Space", 116 | "id": "7yx6ovlj39n5" 117 | } 118 | }, 119 | "id": "4FZb5aqcFiUKASguCEIYei", 120 | "type": "Entry", 121 | "createdAt": "2018-04-19T15:37:49.121Z", 122 | "updatedAt": "2018-04-19T15:37:49.121Z", 123 | "environment": { 124 | "sys": { 125 | "id": "master", 126 | "type": "Link", 127 | "linkType": "Environment" 128 | } 129 | }, 130 | "revision": 1, 131 | "contentType": { 132 | "sys": { 133 | "type": "Link", 134 | "linkType": "ContentType", 135 | "id": "menuButton" 136 | } 137 | }, 138 | "locale": "en-US" 139 | }, 140 | "fields": { 141 | "text": "Register", 142 | "externalLink": "http://serviceu.com" 143 | } 144 | }, 145 | { 146 | "sys": { 147 | "space": { 148 | "sys": { 149 | "type": "Link", 150 | "linkType": "Space", 151 | "id": "7yx6ovlj39n5" 152 | } 153 | }, 154 | "id": "DJfq5Q1ZbayWmgYSGuCSa", 155 | "type": "Entry", 156 | "createdAt": "2018-04-19T15:47:22.395Z", 157 | "updatedAt": "2018-04-19T15:47:22.395Z", 158 | "environment": { 159 | "sys": { 160 | "id": "master", 161 | "type": "Link", 162 | "linkType": "Environment" 163 | } 164 | }, 165 | "revision": 1, 166 | "contentType": { 167 | "sys": { 168 | "type": "Link", 169 | "linkType": "ContentType", 170 | "id": "menuButton" 171 | } 172 | }, 173 | "locale": "en-US" 174 | }, 175 | "fields": { 176 | "text": "Pricing", 177 | "link": { 178 | "sys": { 179 | "type": "Link", 180 | "linkType": "Entry", 181 | "id": "1hAed5DNlg280sgyqaQosW" 182 | } 183 | } 184 | } 185 | } 186 | ] 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/generator.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs-extra' 2 | import * as inflection from 'inflection' 3 | import * as path from 'path' 4 | import { 5 | FormatCodeSettings, 6 | FunctionDeclarationOverloadStructure, 7 | Project, 8 | PropertySignatureStructure, 9 | StructureKind, 10 | } from 'ts-morph' 11 | 12 | import { ContentTypeWriter } from './content-type-writer' 13 | import defaults from './defaults' 14 | 15 | const formatSettings: FormatCodeSettings = { 16 | convertTabsToSpaces: true, 17 | ensureNewLineAtEndOfFile: true, 18 | indentSize: 2, 19 | tabSize: 2, 20 | } 21 | 22 | export interface IGeneratorOptions { 23 | schemaFile: string 24 | outputDir: string 25 | } 26 | 27 | export class ContentfulTSGenerator { 28 | private readonly options: Readonly 29 | 30 | constructor(options?: Partial) { 31 | const opts = Object.assign( 32 | defaults, 33 | options) 34 | 35 | this.options = opts 36 | } 37 | 38 | public generate = async () => { 39 | const options = this.options 40 | const indexFileName = path.join(path.resolve(options.outputDir), 'index.ts') 41 | 42 | if (!await fs.pathExists(options.schemaFile)) { 43 | throw new Error('Schema file does not exist - please use the `download` option to download it from the space') 44 | } 45 | const schemaContents = (await fs.readFile(options.schemaFile)) 46 | const schema = JSON.parse(schemaContents.toString()) 47 | 48 | await fs.mkdirp(options.outputDir) 49 | 50 | const project = new Project() 51 | const indexFile = project.createSourceFile(indexFileName, undefined, {overwrite: true}) 52 | 53 | const typeDirectory = {} as { [id: string]: string } 54 | const fieldsDirectory = {} as { [id: string]: string } 55 | const classDirectory = {} as { [id: string]: string } 56 | await Promise.all(schema.contentTypes.map(async (ct: any) => { 57 | const fileName = idToFilename(ct.sys.id) 58 | 59 | const fullPath = path.join(path.resolve(options.outputDir), fileName + '.ts') 60 | const file = project.createSourceFile(fullPath, undefined, { 61 | overwrite: true, 62 | }) 63 | const writer = new ContentTypeWriter(ct, file) 64 | writer.write() 65 | 66 | file.organizeImports() 67 | file.formatText(formatSettings) 68 | await file.save() 69 | 70 | // export * from './${fileName} 71 | indexFile.addExportDeclaration({ 72 | moduleSpecifier: `./${fileName}`, 73 | }) 74 | 75 | typeDirectory[ct.sys.id] = writer.interfaceName 76 | fieldsDirectory[ct.sys.id] = writer.fieldsName 77 | classDirectory[ct.sys.id] = writer.className 78 | })) 79 | 80 | // import * as C from '.' 81 | indexFile.addImportDeclaration({ 82 | namespaceImport: 'C', 83 | moduleSpecifier: '.', 84 | }) 85 | // import { IEntry } from '../base' 86 | indexFile.addImportDeclaration({ 87 | namedImports: ['IEntry'], 88 | moduleSpecifier: '../base', 89 | }) 90 | 91 | indexFile.addInterface({ 92 | name: 'TypeDirectory', 93 | isExported: true, 94 | properties: Object.keys(typeDirectory).map((ct: any) => ( 95 | { 96 | name: `'${ct}'`, 97 | kind: StructureKind.PropertySignature, 98 | type: `C.${typeDirectory[ct]}`, 99 | } 100 | )), 101 | }) 102 | 103 | indexFile.addInterface({ 104 | name: 'ClassDirectory', 105 | isExported: true, 106 | properties: Object.keys(classDirectory).map((ct: any) => ( 107 | { 108 | name: `'${ct}'`, 109 | kind: StructureKind.PropertySignature, 110 | type: `C.${classDirectory[ct]}`, 111 | } 112 | )), 113 | }) 114 | 115 | const wrapOverloads = Object.keys(classDirectory) 116 | .map((ct) => ({ 117 | parameters: [{ 118 | name: 'entry', 119 | type: `C.${typeDirectory[ct]}`, 120 | }], 121 | kind: StructureKind.FunctionOverload, 122 | returnType: `C.${classDirectory[ct]}`, 123 | })) 124 | 125 | // wrap(entry: TypeDirectory[CT]): ClassDirectory[CT] 126 | wrapOverloads.push({ 127 | typeParameters: [{ 128 | name: 'CT', 129 | constraint: 'keyof TypeDirectory', 130 | }], 131 | kind: StructureKind.FunctionOverload, 132 | parameters: [{ 133 | name: 'entry', 134 | type: 'TypeDirectory[CT]', 135 | }], 136 | returnType: 'ClassDirectory[CT]', 137 | }) 138 | 139 | // export function wrap(entry: IEntry): IEntry 140 | indexFile.addFunction({ 141 | name: 'wrap', 142 | isExported: true, 143 | parameters: [{ 144 | name: 'entry', 145 | type: 'IEntry', 146 | }], 147 | returnType: 'IEntry', 148 | }).setBodyText(writer => { 149 | writer.writeLine('const id = entry.sys.contentType.sys.id') 150 | .writeLine('switch(id) {') 151 | 152 | Object.keys(classDirectory).map((ct) => { 153 | writer.writeLine(`case '${ct}':`) 154 | .writeLine(`return new C.${classDirectory[ct]}(entry)`) 155 | }) 156 | writer.writeLine('default:') 157 | writer.writeLine('throw new Error(\'Unknown content type:\' + id)') 158 | writer.writeLine('}') 159 | }).addOverloads(wrapOverloads) 160 | 161 | indexFile.formatText(formatSettings) 162 | await indexFile.save() 163 | } 164 | } 165 | 166 | function idToFilename(id: string) { 167 | return inflection.underscore(id, false) 168 | } 169 | -------------------------------------------------------------------------------- /src/integration.test.ts: -------------------------------------------------------------------------------- 1 | import {createClient} from 'contentful' 2 | import * as fs from 'fs-extra' 3 | import nock from 'nock' 4 | import * as path from 'path' 5 | import * as tmp from 'tmp' 6 | import {Project} from 'ts-morph' 7 | import { promisify } from 'util' 8 | 9 | import { ContentfulTSGenerator } from './index' 10 | import { Installer } from './installer' 11 | 12 | const client = createClient({ 13 | accessToken: 'xxx', 14 | space: 'testspace', 15 | responseLogger, 16 | requestLogger, 17 | } as any) 18 | 19 | describe('integration', () => { 20 | let tmpDir: string 21 | 22 | beforeEach(async () => { 23 | tmpDir = await (promisify((cb) => tmp.dir(cb))()) 24 | 25 | const installer = new Installer({ 26 | outputDir: tmpDir, 27 | }) 28 | 29 | const generator = new ContentfulTSGenerator({ 30 | outputDir: path.join(tmpDir, 'generated'), 31 | schemaFile: path.join(__dirname, 'fixtures/contentful-schema.json'), 32 | }) 33 | await Promise.all([ 34 | installer.install(), 35 | generator.generate(), 36 | ]) 37 | }) 38 | 39 | it('generates menu.ts', async () => { 40 | 41 | const menuPath = path.join(tmpDir, 'generated/menu.ts') 42 | 43 | const project = new Project() 44 | const menuFile = project.addSourceFileAtPath(menuPath) 45 | 46 | expect(menuFile.getClasses().length).toEqual(1) 47 | expect(menuFile.getClasses()[0].getName()).toEqual('Menu') 48 | }) 49 | 50 | it('Menu can be instantiated from raw entry', async () => { 51 | 52 | const menuPath = path.join(tmpDir, 'generated/menu.ts') 53 | 54 | const { Menu } = require(menuPath) 55 | 56 | const menu = new Menu({ 57 | sys: { id: 'test' }, 58 | fields: { 59 | name: 'test name', 60 | items: [ 61 | { 62 | sys: { 63 | type: 'Link', 64 | id: 'test button', 65 | linkType: 'Entry', 66 | }, 67 | }, 68 | ], 69 | }, 70 | }) 71 | 72 | expect(menu.name).toEqual('test name') 73 | expect(menu.items).toEqual([null]) 74 | expect(menu.sys).toEqual({ id: 'test' }) 75 | expect(menu.fields.name).toEqual('test name') 76 | }) 77 | 78 | it('Menu creates wrapped classes for resolved links', async () => { 79 | 80 | const menuPath = path.join(tmpDir, 'generated/menu.ts') 81 | const buttonPath = path.join(tmpDir, 'generated/menu_button.ts') 82 | 83 | const { Menu } = require(menuPath) 84 | const { MenuButton } = require(buttonPath) 85 | 86 | const menu = new Menu({ 87 | sys: { id: 'test' }, 88 | fields: { 89 | name: 'test name', 90 | items: [ 91 | { 92 | sys: { 93 | type: 'Entry', 94 | id: 'test button', 95 | contentType: { 96 | sys: { 97 | id: 'menuButton', 98 | }, 99 | }, 100 | }, 101 | fields: { 102 | title: 'Button Title', 103 | }, 104 | }, 105 | ], 106 | }, 107 | }) 108 | 109 | const button = menu.items[0] 110 | expect(button instanceof MenuButton).toBeTruthy() 111 | }) 112 | 113 | it('Menu can wrap Contentful.js objects', async () => { 114 | 115 | const menuPath = path.join(tmpDir, 'generated/menu.ts') 116 | const buttonPath = path.join(tmpDir, 'generated/menu_button.ts') 117 | 118 | const { Menu } = require(menuPath) 119 | const { MenuButton } = require(buttonPath) 120 | 121 | nockEnvironment() 122 | nock('https://cdn.contentful.com') 123 | .get('/spaces/testspace/environments/master/entries?sys.id=20bohaVp20MyKSi0YCaw8s') 124 | .replyWithFile(200, path.join(__dirname, 'fixtures/menu.json')) 125 | 126 | const menu = new Menu(await client.getEntry('20bohaVp20MyKSi0YCaw8s')) 127 | const button = menu.items[0] 128 | expect(button instanceof MenuButton).toBeTruthy() 129 | expect(button.text).toEqual('Pricing') 130 | }) 131 | 132 | it('Menu resolve gets linked objects', async () => { 133 | 134 | const menuPath = path.join(tmpDir, 'generated/menu.ts') 135 | const buttonPath = path.join(tmpDir, 'generated/menu_button.ts') 136 | 137 | // symlink "contentful" to pretend like it's been installed in node_modules 138 | const modulesDir = path.join(tmpDir, 'node_modules') 139 | await fs.mkdirp(modulesDir) 140 | await fs.symlink(path.join(__dirname, '../node_modules/contentful'), path.join(modulesDir, 'contentful')) 141 | 142 | // require the index path to get "ext" 143 | require(tmpDir) 144 | const { Menu } = require(menuPath) 145 | const { MenuButton } = require(buttonPath) 146 | 147 | const menu = new Menu('20bohaVp20MyKSi0YCaw8s', 'menu', { 148 | name: 'Side Hamburger', 149 | items: [ 150 | { sys: { type: 'Link', linkType: 'Entry', id: 'DJfq5Q1ZbayWmgYSGuCSa' } }, 151 | { sys: { type: 'Link', linkType: 'Entry', id: '4FZb5aqcFiUKASguCEIYei' } }, 152 | { sys: { type: 'Link', linkType: 'Entry', id: '2kwNEGo2kcoIWCOICASIqk' } }, 153 | ], 154 | }) 155 | 156 | nockEnvironment() 157 | nock('https://cdn.contentful.com') 158 | .get('/spaces/testspace/environments/master/entries') 159 | .query({ 160 | 'sys.id': '20bohaVp20MyKSi0YCaw8s', 161 | 'include': 1, 162 | 'otherParam': 'test', 163 | }) 164 | .replyWithFile(200, path.join(__dirname, 'fixtures/menu.json')) 165 | const resolved = await menu.resolve(1, client, { otherParam: 'test' }) 166 | 167 | const button = menu.items[0] 168 | expect(button instanceof MenuButton).toBeTruthy() 169 | expect(button.text).toEqual('Pricing') 170 | expect(resolved.fields.items[0].fields.text).toEqual('Pricing') 171 | }) 172 | }) 173 | 174 | function nockEnvironment() { 175 | nock('https://cdn.contentful.com') 176 | .get('/spaces/testspace') 177 | .reply(200, { 178 | name: 'Test Space', 179 | sys: { 180 | type: 'Space', 181 | id: 'testspace', 182 | version: 2, 183 | createdBy: { 184 | sys: { 185 | type: 'Link', 186 | linkType: 'User', 187 | id: 'xxxxx', 188 | }, 189 | }, 190 | createdAt: '2018-01-22T17:49:19Z', 191 | updatedBy: { 192 | sys: { 193 | type: 'Link', 194 | linkType: 'User', 195 | id: 'xxxx', 196 | }, 197 | }, 198 | updatedAt: '2018-05-30T10:22:40Z', 199 | }, 200 | }) 201 | 202 | nock('https://cdn.contentful.com') 203 | .get('/spaces/testspace/environments/master') 204 | .reply(200, { 205 | name: 'master', 206 | sys: { 207 | type: 'Environment', 208 | id: 'master', 209 | version: 1, 210 | space: { 211 | sys: { 212 | type: 'Link', 213 | linkType: 'Space', 214 | id: 'testspace', 215 | }, 216 | }, 217 | status: { 218 | sys: { 219 | type: 'Link', 220 | linkType: 'Status', 221 | id: 'ready', 222 | }, 223 | }, 224 | createdBy: { 225 | sys: { 226 | type: 'Link', 227 | linkType: 'User', 228 | id: 'xxxx', 229 | }, 230 | }, 231 | createdAt: '2018-01-22T17:49:19Z', 232 | updatedBy: { 233 | sys: { 234 | type: 'Link', 235 | linkType: 'User', 236 | id: 'xxxx', 237 | }, 238 | }, 239 | updatedAt: '2018-01-22T17:49:19Z', 240 | }, 241 | }) 242 | } 243 | 244 | function requestLogger(config: any) { 245 | // console.log('req', config) 246 | } 247 | 248 | function responseLogger(response: any) { 249 | // tslint:disable-next-line:no-console 250 | console.log(response.status, response.config.url) 251 | } 252 | -------------------------------------------------------------------------------- /src/schema-downloader/fixtures/contentful-schema-from-export.json: -------------------------------------------------------------------------------- 1 | { 2 | "contentTypes": [ 3 | { 4 | "sys": { 5 | "space": { 6 | "sys": { 7 | "type": "Link", 8 | "linkType": "Space", 9 | "id": "7yx6ovlj39n5" 10 | } 11 | }, 12 | "id": "testimonial", 13 | "type": "ContentType", 14 | "createdAt": "2018-04-16T18:37:13.823Z", 15 | "updatedAt": "2018-07-13T21:47:33.809Z", 16 | "environment": { 17 | "sys": { 18 | "id": "staging", 19 | "type": "Link", 20 | "linkType": "Environment" 21 | } 22 | }, 23 | "createdBy": { 24 | "sys": { 25 | "type": "Link", 26 | "linkType": "User", 27 | "id": "0SUbYs2vZlXjVR6bH6o83O" 28 | } 29 | }, 30 | "updatedBy": { 31 | "sys": { 32 | "type": "Link", 33 | "linkType": "User", 34 | "id": "2Rw3pZANDXJEtKNoPuE40I" 35 | } 36 | }, 37 | "publishedCounter": 8, 38 | "version": 16, 39 | "publishedBy": { 40 | "sys": { 41 | "type": "Link", 42 | "linkType": "User", 43 | "id": "2Rw3pZANDXJEtKNoPuE40I" 44 | } 45 | }, 46 | "publishedVersion": 15, 47 | "firstPublishedAt": "2018-04-16T18:37:14.044Z", 48 | "publishedAt": "2018-07-13T21:47:33.809Z" 49 | }, 50 | "displayField": "name", 51 | "name": "Testimonial", 52 | "description": "A Testimonial contains a user's photo...", 53 | "fields": [ 54 | { 55 | "id": "name", 56 | "name": "Name", 57 | "type": "Symbol", 58 | "localized": false, 59 | "required": true, 60 | "validations": [ 61 | 62 | ], 63 | "disabled": false, 64 | "omitted": false 65 | }, 66 | { 67 | "id": "photo", 68 | "name": "Photo", 69 | "type": "Link", 70 | "localized": false, 71 | "required": true, 72 | "validations": [ 73 | { 74 | "linkMimetypeGroup": [ 75 | "image" 76 | ] 77 | } 78 | ], 79 | "disabled": false, 80 | "omitted": false, 81 | "linkType": "Asset" 82 | } 83 | ] 84 | }, 85 | { 86 | "sys": { 87 | "space": { 88 | "sys": { 89 | "type": "Link", 90 | "linkType": "Space", 91 | "id": "7yx6ovlj39n5" 92 | } 93 | }, 94 | "id": "menu", 95 | "type": "ContentType", 96 | "createdAt": "2018-04-16T18:36:05.899Z", 97 | "updatedAt": "2018-06-29T21:20:42.134Z", 98 | "environment": { 99 | "sys": { 100 | "id": "staging", 101 | "type": "Link", 102 | "linkType": "Environment" 103 | } 104 | }, 105 | "createdBy": { 106 | "sys": { 107 | "type": "Link", 108 | "linkType": "User", 109 | "id": "0SUbYs2vZlXjVR6bH6o83O" 110 | } 111 | }, 112 | "updatedBy": { 113 | "sys": { 114 | "type": "Link", 115 | "linkType": "User", 116 | "id": "2Rw3pZANDXJEtKNoPuE40I" 117 | } 118 | }, 119 | "publishedCounter": 12, 120 | "version": 24, 121 | "publishedBy": { 122 | "sys": { 123 | "type": "Link", 124 | "linkType": "User", 125 | "id": "2Rw3pZANDXJEtKNoPuE40I" 126 | } 127 | }, 128 | "publishedVersion": 23, 129 | "firstPublishedAt": "2018-04-16T18:36:06.396Z", 130 | "publishedAt": "2018-06-29T21:20:42.134Z" 131 | }, 132 | "displayField": "name", 133 | "name": "Menu", 134 | "description": "A Menu contains...", 135 | "fields": [ 136 | { 137 | "id": "name", 138 | "name": "Name", 139 | "type": "Symbol", 140 | "localized": false, 141 | "required": false, 142 | "validations": [ 143 | 144 | ], 145 | "disabled": false, 146 | "omitted": false 147 | }, 148 | { 149 | "id": "items", 150 | "name": "Items", 151 | "type": "Array", 152 | "localized": false, 153 | "required": false, 154 | "validations": [ 155 | 156 | ], 157 | "disabled": false, 158 | "omitted": false, 159 | "items": { 160 | "type": "Link", 161 | "validations": [ 162 | { 163 | "linkContentType": [ 164 | "dropdownMenu", 165 | "menuButton" 166 | ], 167 | "message": "The Menu groups must contain only sub-Menus or MenuButtons" 168 | } 169 | ], 170 | "linkType": "Entry" 171 | } 172 | } 173 | ] 174 | } 175 | ], 176 | "editorInterfaces": [ 177 | { 178 | "sys": { 179 | "id": "default", 180 | "type": "EditorInterface", 181 | "space": { 182 | "sys": { 183 | "id": "7yx6ovlj39n5", 184 | "type": "Link", 185 | "linkType": "Space" 186 | } 187 | }, 188 | "version": 13, 189 | "createdAt": "2018-04-16T18:36:06.769Z", 190 | "createdBy": { 191 | "sys": { 192 | "id": "0SUbYs2vZlXjVR6bH6o83O", 193 | "type": "Link", 194 | "linkType": "User" 195 | } 196 | }, 197 | "updatedAt": "2018-06-29T21:20:42.440Z", 198 | "updatedBy": { 199 | "sys": { 200 | "id": "2Rw3pZANDXJEtKNoPuE40I", 201 | "type": "Link", 202 | "linkType": "User" 203 | } 204 | }, 205 | "contentType": { 206 | "sys": { 207 | "id": "menu", 208 | "type": "Link", 209 | "linkType": "ContentType" 210 | } 211 | }, 212 | "environment": { 213 | "sys": { 214 | "id": "staging", 215 | "type": "Link", 216 | "linkType": "Environment" 217 | } 218 | } 219 | }, 220 | "controls": [ 221 | { 222 | "fieldId": "name", 223 | "settings": { 224 | "helpText": "This is the name" 225 | }, 226 | "widgetId": "singleLine" 227 | }, 228 | { 229 | "fieldId": "items", 230 | "widgetId": "entryLinksEditor" 231 | } 232 | ] 233 | }, 234 | { 235 | "sys": { 236 | "id": "default", 237 | "type": "EditorInterface", 238 | "space": { 239 | "sys": { 240 | "id": "7yx6ovlj39n5", 241 | "type": "Link", 242 | "linkType": "Space" 243 | } 244 | }, 245 | "version": 13, 246 | "createdAt": "2018-04-16T18:36:06.769Z", 247 | "createdBy": { 248 | "sys": { 249 | "id": "0SUbYs2vZlXjVR6bH6o83O", 250 | "type": "Link", 251 | "linkType": "User" 252 | } 253 | }, 254 | "updatedAt": "2018-06-29T21:20:42.440Z", 255 | "updatedBy": { 256 | "sys": { 257 | "id": "2Rw3pZANDXJEtKNoPuE40I", 258 | "type": "Link", 259 | "linkType": "User" 260 | } 261 | }, 262 | "contentType": { 263 | "sys": { 264 | "id": "testimonial", 265 | "type": "Link", 266 | "linkType": "ContentType" 267 | } 268 | }, 269 | "environment": { 270 | "sys": { 271 | "id": "staging", 272 | "type": "Link", 273 | "linkType": "Environment" 274 | } 275 | } 276 | }, 277 | "controls": [ 278 | { 279 | "fieldId": "name", 280 | "widgetId": "singleLine", 281 | "widgetNamespace": "builtin" 282 | }, 283 | { 284 | "fieldId": "photo", 285 | "widgetId": "assetLinkEditor", 286 | "widgetNamespace": "builtin" 287 | } 288 | ] 289 | } 290 | ] 291 | } -------------------------------------------------------------------------------- /src/fixtures/contentful-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "contentTypes": [ 3 | { 4 | "sys": { 5 | "id": "menu", 6 | "type": "ContentType" 7 | }, 8 | "displayField": "name", 9 | "name": "Menu", 10 | "description": "A Menu contains...", 11 | "fields": [ 12 | { 13 | "id": "name", 14 | "name": "Name", 15 | "type": "Symbol", 16 | "localized": false, 17 | "required": false, 18 | "validations": [], 19 | "disabled": false, 20 | "omitted": false 21 | }, 22 | { 23 | "id": "items", 24 | "name": "Items", 25 | "type": "Array", 26 | "localized": false, 27 | "required": false, 28 | "validations": [], 29 | "disabled": false, 30 | "omitted": false, 31 | "items": { 32 | "type": "Link", 33 | "validations": [ 34 | { 35 | "linkContentType": [ 36 | "menuButton" 37 | ], 38 | "message": "The Menu groups must contain only MenuButtons" 39 | } 40 | ], 41 | "linkType": "Entry" 42 | } 43 | } 44 | ] 45 | }, 46 | { 47 | "sys": { 48 | "id": "menuButton", 49 | "type": "ContentType", 50 | "contentType": null 51 | }, 52 | "displayField": "internalTitle", 53 | "name": "Menu Button", 54 | "description": "A Menu Button is a clickable button that goes on a Menu.", 55 | "fields": [ 56 | { 57 | "id": "internalTitle", 58 | "name": "Internal Title (Contentful Only)", 59 | "type": "Symbol", 60 | "localized": false, 61 | "required": true, 62 | "validations": [], 63 | "disabled": false, 64 | "omitted": true 65 | }, 66 | { 67 | "id": "text", 68 | "name": "Text", 69 | "type": "Symbol", 70 | "localized": false, 71 | "required": false, 72 | "validations": [ 73 | { 74 | "size": { 75 | "min": 1, 76 | "max": 60 77 | }, 78 | "message": "A Menu Button should have a very short text field." 79 | } 80 | ], 81 | "disabled": false, 82 | "omitted": false 83 | }, 84 | { 85 | "id": "icon", 86 | "name": "Icon", 87 | "type": "Link", 88 | "localized": false, 89 | "required": false, 90 | "validations": [ 91 | { 92 | "linkMimetypeGroup": [ 93 | "image" 94 | ] 95 | } 96 | ], 97 | "disabled": false, 98 | "omitted": false, 99 | "linkType": "Asset" 100 | }, 101 | { 102 | "id": "externalLink", 103 | "name": "External Link", 104 | "type": "Symbol", 105 | "localized": false, 106 | "required": false, 107 | "validations": [ 108 | { 109 | "regexp": { 110 | "pattern": "^([^\\s\\:]+):(\\/\\/)?(\\w+:{0,1}\\w*@)?(([^\\s\\/#]+\\.)+[^\\s\\/#]+)(:[0-9]+)?(\\/|(\\/|\\#)([\\w#!:.?+=&%@!\\-\\/]+))?$|^(\\/|(\\/|\\#)([\\w#!:.?+=&%@!\\-\\/]+))$" 111 | }, 112 | "message": "The external link must be a URL like 'https://www.watermark.org/', a mailto url like 'mailto:info@watermark.org', or a relative URL like '#location-on-page'" 113 | } 114 | ], 115 | "disabled": false, 116 | "omitted": false 117 | }, 118 | { 119 | "id": "link", 120 | "name": "Page Link", 121 | "type": "Link", 122 | "localized": false, 123 | "required": false, 124 | "validations": [], 125 | "disabled": false, 126 | "omitted": false, 127 | "linkType": "Entry" 128 | }, 129 | { 130 | "id": "ionIcon", 131 | "name": "Ion Icon", 132 | "type": "Symbol", 133 | "localized": false, 134 | "required": false, 135 | "validations": [ 136 | { 137 | "regexp": { 138 | "pattern": "^ion-[a-z\\-]+$" 139 | }, 140 | "message": "The icon should start with 'ion-', like 'ion-arrow-down-c'. See http://ionicons.com/" 141 | } 142 | ], 143 | "disabled": false, 144 | "omitted": false 145 | }, 146 | { 147 | "id": "style", 148 | "name": "Style", 149 | "type": "Symbol", 150 | "localized": false, 151 | "required": false, 152 | "validations": [ 153 | { 154 | "in": [ 155 | "oval-border" 156 | ] 157 | } 158 | ], 159 | "disabled": false, 160 | "omitted": false 161 | } 162 | ] 163 | }, 164 | { 165 | "sys": { 166 | "id": "testimonial", 167 | "type": "ContentType" 168 | }, 169 | "displayField": "name", 170 | "name": "Testimonial", 171 | "description": "A Testimonial contains a user's photo...", 172 | "fields": [ 173 | { 174 | "id": "name", 175 | "name": "Name", 176 | "type": "Symbol", 177 | "localized": false, 178 | "required": true, 179 | "validations": [], 180 | "disabled": false, 181 | "omitted": false 182 | }, 183 | { 184 | "id": "photo", 185 | "name": "Photo", 186 | "type": "Link", 187 | "localized": false, 188 | "required": true, 189 | "validations": [ 190 | { 191 | "linkMimetypeGroup": [ 192 | "image" 193 | ] 194 | } 195 | ], 196 | "disabled": false, 197 | "omitted": false, 198 | "linkType": "Asset" 199 | } 200 | ] 201 | } 202 | ], 203 | "editorInterfaces": [ 204 | { 205 | "sys": { 206 | "id": "default", 207 | "type": "EditorInterface", 208 | "contentType": { 209 | "sys": { 210 | "id": "menu", 211 | "type": "Link", 212 | "linkType": "ContentType" 213 | } 214 | } 215 | }, 216 | "controls": [ 217 | { 218 | "fieldId": "name", 219 | "widgetId": "singleLine" 220 | }, 221 | { 222 | "fieldId": "items", 223 | "widgetId": "entryLinksEditor" 224 | } 225 | ] 226 | }, 227 | { 228 | "sys": { 229 | "id": "default", 230 | "type": "EditorInterface", 231 | "contentType": { 232 | "sys": { 233 | "id": "testimonial", 234 | "type": "Link", 235 | "linkType": "ContentType" 236 | } 237 | } 238 | }, 239 | "controls": [ 240 | { 241 | "fieldId": "name", 242 | "widgetId": "singleLine" 243 | }, 244 | { 245 | "fieldId": "photo", 246 | "widgetId": "assetLinkEditor" 247 | } 248 | ] 249 | }, 250 | { 251 | "sys": { 252 | "id": "default", 253 | "type": "EditorInterface", 254 | "contentType": { 255 | "sys": { 256 | "id": "menuButton", 257 | "type": "Link", 258 | "linkType": "ContentType" 259 | } 260 | } 261 | }, 262 | "controls": [ 263 | { 264 | "fieldId": "text", 265 | "widgetId": "singleLine" 266 | }, 267 | { 268 | "fieldId": "icon", 269 | "widgetId": "assetLinkEditor" 270 | }, 271 | { 272 | "fieldId": "externalLink", 273 | "settings": { 274 | "helpText": "Provide a URL to something on another website, a `mailto:` link to an email address, or a deep link into an app." 275 | }, 276 | "widgetId": "singleLine" 277 | }, 278 | { 279 | "fieldId": "link", 280 | "widgetId": "entryLinkEditor" 281 | }, 282 | { 283 | "fieldId": "ionIcon", 284 | "widgetId": "singleLine" 285 | }, 286 | { 287 | "fieldId": "internalTitle", 288 | "widgetId": "singleLine" 289 | }, 290 | { 291 | "fieldId": "style", 292 | "widgetId": "dropdown" 293 | } 294 | ] 295 | } 296 | ] 297 | } -------------------------------------------------------------------------------- /src/content-type-writer.ts: -------------------------------------------------------------------------------- 1 | import * as inflection from 'inflection' 2 | import { ClassDeclaration, InterfaceDeclaration, Scope, SourceFile } from 'ts-morph' 3 | import * as util from 'util' 4 | 5 | export class ContentTypeWriter { 6 | public readonly interfaceName: string 7 | public readonly className: string 8 | public readonly fieldsName: string 9 | 10 | private linkedTypes: any[] 11 | 12 | constructor(private readonly contentType: any, private readonly file: SourceFile) { 13 | this.linkedTypes = [] 14 | 15 | const name = idToName(contentType.sys.id) 16 | this.interfaceName = `I${name}` 17 | this.className = name 18 | this.fieldsName = `I${name}Fields` 19 | } 20 | 21 | public write = () => { 22 | const contentType = this.contentType 23 | const file = this.file 24 | 25 | file.addImportDeclaration({ 26 | moduleSpecifier: '../base', 27 | namedImports: ['Asset', 'IAsset', 'Entry', 'IEntry', 'ILink', 'ISys', 'isAsset', 'isEntry'], 28 | }) 29 | file.addImportDeclaration({ 30 | moduleSpecifier: '.', 31 | namedImports: ['wrap'], 32 | }) 33 | 34 | const fieldsInterface = file.addInterface({ 35 | name: this.fieldsName, 36 | isExported: true, 37 | }) 38 | 39 | contentType.fields.forEach((f: any) => 40 | this.writeField(f, fieldsInterface)) 41 | 42 | if (this.linkedTypes.length > 0) { 43 | this.linkedTypes = this.linkedTypes.filter((t, index, self) => self.indexOf(t) === index).sort() 44 | const indexOfSelf = this.linkedTypes.indexOf(contentType.sys.id) 45 | if (indexOfSelf > -1) { 46 | this.linkedTypes.splice(indexOfSelf, 1) 47 | } 48 | 49 | this.linkedTypes.forEach((id) => { 50 | file.addImportDeclaration({ 51 | moduleSpecifier: `./${idToFilename(id)}`, 52 | namedImports: [ 53 | `I${idToName(id)}`, 54 | idToName(id), 55 | ], 56 | }) 57 | }) 58 | } 59 | 60 | file.addInterface({ 61 | name: this.interfaceName, 62 | isExported: true, 63 | docs: [[ 64 | contentType.name, 65 | contentType.description && '', 66 | contentType.description && contentType.description, 67 | ].filter(exists).join('\n')], 68 | extends: [`IEntry<${this.fieldsName}>`], 69 | }) 70 | 71 | file.addFunction({ 72 | name: `is${this.className}`, 73 | isExported: true, 74 | parameters: [{ 75 | name: 'entry', 76 | type: 'IEntry', 77 | }], 78 | returnType: `entry is ${this.interfaceName}`, 79 | }).setBodyText(writer => { 80 | writer.writeLine('return entry &&') 81 | .writeLine('entry.sys &&') 82 | .writeLine('entry.sys.contentType &&') 83 | .writeLine('entry.sys.contentType.sys &&') 84 | .writeLine(`entry.sys.contentType.sys.id == '${contentType.sys.id}'`) 85 | }) 86 | 87 | const klass = file.addClass({ 88 | name: this.className, 89 | isExported: true, 90 | extends: `Entry<${this.fieldsName}>`, 91 | implements: [this.interfaceName], 92 | properties: [ 93 | // These are inherited from the base class and do not need to be redefined here. 94 | // Further, babel 7 transforms the constructor in such a way that the `Object.assign(this, entryOrId)` 95 | // in the base class gets wiped if these properties are present. 96 | // { name: 'sys', isReadonly: true, hasExclamationToken: true, scope: Scope.Public, type: `ISys<'Entry'>` }, 97 | // { name: 'fields', isReadonly: true, hasExclamationToken: true, scope: Scope.Public, type: this.fieldsName }, 98 | ], 99 | }) 100 | 101 | contentType.fields.filter((f: any) => !f.omitted) 102 | .map((f: any) => this.writeFieldAccessor(f, klass)) 103 | 104 | klass.addConstructor({ 105 | parameters: [{ 106 | name: 'entryOrId', 107 | type: `${this.interfaceName} | string`, 108 | }, { 109 | name: 'fields', 110 | hasQuestionToken: true, 111 | type: this.fieldsName, 112 | }], 113 | overloads: [ 114 | { parameters: [{ name: 'entry', type: this.interfaceName }] }, 115 | { parameters: [{ name: 'id', type: 'string' }, { name: 'fields', type: this.fieldsName }] }, 116 | ], 117 | }).setBodyText(`super(entryOrId, '${contentType.sys.id}', fields)`) 118 | } 119 | 120 | public writeField(field: any, fieldsInterface: InterfaceDeclaration) { 121 | fieldsInterface.addProperty({ 122 | name: field.id, 123 | hasQuestionToken: field.omitted || (!field.required), 124 | type: this.writeFieldType(field), 125 | }) 126 | } 127 | 128 | public writeFieldAccessor(field: any, klass: ClassDeclaration) { 129 | let accessorImpl = `return this.fields.${field.id}` 130 | let returnType = `` 131 | 132 | const getLinkImpl = (f: any, val: string) => { 133 | if (f.linkType == 'Asset') { 134 | returnType = `Asset | null` 135 | return `isAsset(${val}) ? new Asset(${val}) : null` 136 | } else { 137 | const validation = f.validations && 138 | f.validations.find((v: any) => v.linkContentType && v.linkContentType.length > 0) 139 | if (validation) { 140 | const union = validation.linkContentType.map((ct: string) => `'${ct}'`).join(' | ') 141 | 142 | if (validation.linkContentType.length == 1) { 143 | returnType = `${idToName(validation.linkContentType[0])} | null` 144 | } else { 145 | returnType = `${unionTypeDefName(this.contentType.sys.id, f)}Class | null` 146 | } 147 | 148 | return `isEntry(${val}) ? wrap<${union}>(${val}) : null` 149 | } else { 150 | returnType = `IEntry | null` 151 | return `isEntry(${val}) ? wrap(${val}) : null` 152 | } 153 | } 154 | } 155 | 156 | const optionalFieldDeclaration = field.required && !field.omitted ? '' : ' | undefined' 157 | 158 | if (field.type == 'Link') { 159 | accessorImpl = 'return ' 160 | if (!field.required) { 161 | accessorImpl += `!this.fields.${field.id} ? undefined :\n` 162 | } 163 | accessorImpl += `(${getLinkImpl(field, `this.fields.${field.id}`)})` 164 | returnType = `${returnType}${optionalFieldDeclaration}` 165 | } else if (field.type == 'Array' && field.items.type == 'Link') { 166 | const mapFnImpl = getLinkImpl(Object.assign({ id: field.id }, field.items), 'item') 167 | accessorImpl = 'return ' 168 | if (!field.required) { 169 | accessorImpl += `!this.fields.${field.id} ? undefined :\n` 170 | } 171 | 172 | accessorImpl += `this.fields.${field.id}.map((item) => 173 | ${mapFnImpl} 174 | )` 175 | 176 | returnType = `Array<${returnType}>${optionalFieldDeclaration}` 177 | } else { 178 | returnType = `${this.writeFieldType(field)}${optionalFieldDeclaration}` 179 | } 180 | 181 | const fieldId = ['fields', 'sys'].indexOf(field.id) >= 0 ? 182 | // section-contact-us has a field named 'fields' 183 | field.id + '_get' : 184 | field.id 185 | const underscored = inflection.underscore(fieldId) 186 | 187 | klass.addGetAccessor({ 188 | name: fieldId, 189 | returnType, 190 | }).setBodyText(accessorImpl) 191 | 192 | if (underscored != fieldId) { 193 | klass.addGetAccessor({ 194 | name: underscored, 195 | returnType, 196 | }).setBodyText(accessorImpl) 197 | } 198 | } 199 | 200 | public writeFieldType(field: any): string { 201 | if (field.omitted) { 202 | return 'never' 203 | } 204 | switch (field.type) { 205 | case 'Symbol': 206 | case 'Text': 207 | case 'Date': 208 | return this.writePotentialUnionType(field) || 'string' 209 | case 'Integer': 210 | case 'Number': 211 | return this.writePotentialUnionType(field) || 'number' 212 | case 'Boolean': 213 | return 'boolean' 214 | case 'Location': 215 | return '{ lon: number, lat: number }' 216 | case 'Link': 217 | if (field.linkType == 'Asset') { 218 | return 'ILink<\'Asset\'> | IAsset' 219 | } else { 220 | return `ILink<'Entry'> | ${this.resolveLinkContentType(field)}` 221 | } 222 | case 'Array': 223 | const itemType = this.writeFieldType(Object.assign({ id: field.id }, field.items)) 224 | if (itemType.includes(' | ') || itemType.includes('{')) { 225 | return `Array<${itemType}>` 226 | } else { 227 | return itemType + '[]' 228 | } 229 | default: 230 | return 'any' 231 | } 232 | } 233 | 234 | public resolveLinkContentType(field: any) { 235 | if (field.validations) { 236 | const validation = field.validations.find((v: any) => v.linkContentType && v.linkContentType.length > 0) 237 | if (validation) { 238 | this.linkedTypes.push(...validation.linkContentType) 239 | if (validation.linkContentType.length == 1) { 240 | const name = idToName(validation.linkContentType[0]) 241 | return ('I' + name) 242 | } 243 | 244 | const unionName = unionTypeDefName(this.contentType.sys.id, field) 245 | if (!this.file.getTypeAlias(unionName)) { 246 | this.file.addTypeAlias({ 247 | name: unionName, 248 | isExported: true, 249 | type: validation.linkContentType.map((v: any) => 'I' + idToName(v)).join(' | '), 250 | }) 251 | 252 | this.file.addTypeAlias({ 253 | name: unionName + 'Class', 254 | isExported: true, 255 | type: validation.linkContentType.map((v: any) => idToName(v)).join(' | '), 256 | }) 257 | } 258 | return unionName 259 | } 260 | } 261 | return 'IEntry' 262 | } 263 | 264 | public writePotentialUnionType(field: any) { 265 | if (field.validations) { 266 | const validation = field.validations.find((v: any) => v.in && v.in.length > 0) 267 | if (validation) { 268 | const name = unionTypeDefName(this.contentType.sys.id, field) 269 | if (!this.file.getTypeAlias(name)) { 270 | this.file.addTypeAlias({ 271 | name, 272 | isExported: true, 273 | type: validation.in.map((val: any) => dump(val)).join(' | '), 274 | }) 275 | } 276 | 277 | return name 278 | } 279 | } 280 | } 281 | } 282 | function idToName(id: string) { 283 | id = inflection.underscore(id) 284 | id = id.replace(/[^\w]/g, ' ') 285 | id = inflection.titleize(id) 286 | id = id.replace(/[\s+]/g, '') 287 | return id 288 | } 289 | 290 | function unionTypeDefName(contentType: string, field: { id: string }) { 291 | return `${idToName(contentType)}${inflection.singularize(idToName(field.id))}` 292 | } 293 | 294 | function idToFilename(id: string) { 295 | return inflection.underscore(id, false) 296 | } 297 | 298 | function dump(obj: any) { 299 | return util.inspect(obj, { 300 | depth: null, 301 | maxArrayLength: null, 302 | breakLength: 0, 303 | }) 304 | } 305 | 306 | function exists(val: any): boolean { 307 | return !!val 308 | } 309 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # contentful-ts-generator 2 | 3 | [![npm version](https://badge.fury.io/js/contentful-ts-generator.svg)](https://badge.fury.io/js/contentful-ts-generator) 4 | [![Build Status](https://travis-ci.org/watermarkchurch/ts-generators.svg?branch=master)](https://travis-ci.org/watermarkchurch/ts-generators) 5 | [![Coverage Status](https://coveralls.io/repos/github/watermarkchurch/ts-generators/badge.svg?branch=master)](https://coveralls.io/github/watermarkchurch/ts-generators?branch=master) 6 | 7 | A CLI & webpack plugin for automatically generating Typescript code based on the 8 | content types in your Contentful space. 9 | 10 | ### Installation: 11 | ``` 12 | npm install contentful-ts-generator 13 | ``` 14 | 15 | ### Usage: 16 | 17 | #### CLI: 18 | ``` 19 | $ node_modules/.bin/contentful-ts-generator --help 20 | Options: 21 | --help Show help [boolean] 22 | --version Show version number [boolean] 23 | --file, -f The location on disk of the schema file. 24 | --out, -o Where to place the generated code. 25 | --download, -d Whether to download the schema file from the Contentful 26 | space first [boolean] 27 | --managementToken, -m The Contentful management token. Defaults to the env 28 | var CONTENTFUL_MANAGEMENT_TOKEN 29 | --space, -s The Contentful space ID. Defaults to the env var 30 | CONTENTFUL_SPACE_ID 31 | --environment, -e The Contentful environment. Defaults to the env var 32 | CONTENTFUL_ENVIRONMENT or 'master' 33 | ``` 34 | 35 | It requires no parameters to function, provided you've set the appropriate environment 36 | variables or have already downloaded a `contentful-schema.json` file. By default, 37 | in a Rails project it will look for `db/contentful-schema.json` and generate 38 | Typescript files in `app/assets/javascripts/lib/contentful/generated`. 39 | 40 | #### Webpack plugin 41 | 42 | In your webpack.config.js: 43 | ```js 44 | const ContentfulTsGenerator = require('contentful-ts-generator') 45 | 46 | module.exports = { 47 | ... 48 | plugins: [ 49 | new ContentfulTsGenerator.ContentfulTsGeneratorPlugin({ 50 | /** (Optional) The location on disk of the schema file. */ 51 | schemaFile: 'db/contentful-schema.json', 52 | /** (Optional) Where to place the generated code. */ 53 | outputDir: 'app/assets/javascripts/lib/contentful', 54 | /** 55 | * (Optional) Whether to download the schema file from the Contentful space first. 56 | * This can take a long time - it's best to set this to "false" and commit your 57 | * contentful-schema.json to the repository. 58 | */ 59 | downloadSchema: true, 60 | /** (Optional) The Contentful space ID. Defaults to the env var CONTENTFUL_SPACE_ID */ 61 | space: '1xab...', 62 | /** (Optional) The Contentful environment. Defaults to the env var CONTENTFUL_ENVIRONMENT or \'master\' */ 63 | environment: 'master', 64 | /** (Optional) The Contentful management token. Defaults to the env var CONTENTFUL_MANAGEMENT_TOKEN */ 65 | managementToken: 'xxxx', 66 | }) 67 | ] 68 | }; 69 | ``` 70 | 71 | or in `config/webpack/environment.js` for a 72 | [webpacker](https://github.com/rails/webpacker) project 73 | ```js 74 | const { ContentfulTsGeneratorPlugin } = require('contentful-ts-generator') 75 | environment.plugins.append('ContentfulTsGenerator', new ContentfulTsGeneratorPlugin({ 76 | // options 77 | })) 78 | ``` 79 | 80 | #### Example: 81 | 82 | ```tsx 83 | import { ContentfulClientApi } from 'contentful' 84 | import { Resolved } from './lib/contentful' 85 | import { 86 | IMenu 87 | } from './lib/contentful/generated' 88 | 89 | interface IProps { 90 | menuId: string, 91 | client: ContentfulClientApi 92 | } 93 | 94 | interface IState { 95 | resolvedMenu: Resolved 96 | } 97 | 98 | export class MenuRenderer extends React.Component { 99 | 100 | public componentDidMount() { 101 | this.loadMenu() 102 | } 103 | 104 | public render() { 105 | const { resolvedMenu } = this.state 106 | 107 | if (!resolvedMenu) { 108 | return
Loading...
109 | } 110 | 111 | return
112 | {resolvedMenu.fields.items.map( 113 | // no need to cast here, the generated interface tells us it's an IMenuButton 114 | (btn) => ( 115 | 116 | 117 | {btn.fields.text} 118 | 119 | ) 120 | )} 121 |
122 | } 123 | 124 | private async loadMenu() { 125 | const { menuId, client } = this.props 126 | 127 | // By default, client.getEntry resolves one level of links. 128 | // This is represented with the `Resolved` type, which is what gets 129 | // returned here. 130 | const resolvedMenu = await client.getEntry(menuId) 131 | 132 | this.setState({ 133 | resolvedMenu 134 | }) 135 | } 136 | } 137 | ``` 138 | 139 | What does `'generated/menu.ts'` look like? 140 | 141 | Given a content type defined like this: 142 | ```json 143 | { 144 | "sys": { 145 | "id": "menu", 146 | "type": "ContentType" 147 | }, 148 | "displayField": "internalTitle", 149 | "name": "Menu", 150 | "description": "A Menu contains a number of Menu Buttons or other Menus, which will be rendered as drop-downs.", 151 | "fields": [ 152 | { 153 | "id": "internalTitle", 154 | "name": "Internal Title (Contentful Only)", 155 | "type": "Symbol", 156 | "localized": false, 157 | "required": true, 158 | "validations": [], 159 | "disabled": false, 160 | "omitted": true 161 | }, 162 | { 163 | "id": "name", 164 | "name": "Menu Name", 165 | "type": "Symbol", 166 | "localized": false, 167 | "required": true, 168 | "validations": [], 169 | "disabled": false, 170 | "omitted": false 171 | }, 172 | { 173 | "id": "items", 174 | "name": "Items", 175 | "type": "Array", 176 | "localized": false, 177 | "required": false, 178 | "validations": [], 179 | "disabled": false, 180 | "omitted": false, 181 | "items": { 182 | "type": "Link", 183 | "validations": [ 184 | { 185 | "linkContentType": [ 186 | "cartButton", 187 | "divider", 188 | "dropdownMenu", 189 | "loginButton", 190 | "menuButton" 191 | ], 192 | "message": "The items must be either buttons, drop-down menus, or dividers." 193 | } 194 | ], 195 | "linkType": "Entry" 196 | } 197 | } 198 | ] 199 | } 200 | ``` 201 | 202 | The following types are generated: 203 | ```ts 204 | import { wrap } from "."; 205 | import { IEntry, ILink, isEntry, ISys } from "../base"; 206 | import { CartButton, ICartButton } from "./cart_button"; 207 | import { Divider, IDivider } from "./divider"; 208 | import { DropdownMenu, IDropdownMenu } from "./dropdown_menu"; 209 | import { ILoginButton, LoginButton } from "./login_button"; 210 | import { IMenuButton, MenuButton } from "./menu_button"; 211 | 212 | export interface IMenuFields { 213 | internalTitle?: never; 214 | name: string; 215 | items?: Array | MenuItem>; 216 | } 217 | 218 | export type MenuItem = ICartButton | IDivider | IDropdownMenu | ILoginButton | IMenuButton; 219 | export type MenuItemClass = CartButton | Divider | DropdownMenu | LoginButton | MenuButton; 220 | 221 | /** 222 | * Menu 223 | * A Menu contains a number of Menu Buttons or other Menus, which will be rendered as drop-downs. 224 | */ 225 | export interface IMenu extends IEntry { 226 | } 227 | 228 | export function isMenu(entry: IEntry): entry is IMenu { 229 | return entry && 230 | entry.sys && 231 | entry.sys.contentType && 232 | entry.sys.contentType.sys && 233 | entry.sys.contentType.sys.id == 'menu' 234 | } 235 | 236 | export class Menu implements IMenu { 237 | public readonly sys!: ISys<'Entry'>; 238 | public readonly fields!: IMenuFields; 239 | 240 | get name(): string { 241 | return this.fields.name 242 | } 243 | 244 | get items(): Array | undefined { 245 | return !this.fields.items ? undefined : 246 | this.fields.items.map((item) => 247 | isEntry(item) ? wrap<'cartButton' | 'divider' | 'dropdownMenu' | 'loginButton' | 'menuButton'>(item) : null 248 | ) 249 | } 250 | 251 | constructor(entry: IMenu); 252 | constructor(id: string, fields: IMenuFields); 253 | constructor(entryOrId: IMenu | string, fields?: IMenuFields) { 254 | 255 | if (typeof entryOrId == 'string') { 256 | if (!fields) { 257 | throw new Error('No fields provided') 258 | } 259 | 260 | this.sys = { 261 | id: entryOrId, 262 | type: 'Entry', 263 | space: undefined, 264 | contentType: { 265 | sys: { 266 | type: 'Link', 267 | linkType: 'ContentType', 268 | id: 'menu' 269 | } 270 | } 271 | } 272 | this.fields = fields 273 | } else { 274 | if (typeof entryOrId.sys == 'undefined') { 275 | throw new Error('Entry did not have a `sys`!') 276 | } 277 | if (typeof entryOrId.fields == 'undefined') { 278 | throw new Error('Entry did not have a `fields`!') 279 | } 280 | Object.assign(this, entryOrId) 281 | } 282 | } 283 | } 284 | 285 | ``` 286 | 287 | The interface represents data coming back from Contentful's `getEntry` SDK function. 288 | The generated class can be used as a convenient wrapper. For example: 289 | 290 | ```ts 291 | const menu = new Menu(await client.getEntry('my-menu-id')) 292 | const button0 = menu.items[0] 293 | 294 | expect(button0.text).to.equal('About Us') 295 | ``` 296 | 297 | You can also extend the generated classes with your own functions and properties. 298 | As an example, suppose you wanted to use some client-side logic to determine 299 | whether a certain menu button should be hidden from users. You could define 300 | an `accessLevel` property on menu button: 301 | 302 | ```ts 303 | // in lib/contentful/ext/menu_button.ts 304 | import { MenuButton } from '../generated/menu_button' 305 | 306 | // reopen the MenuButton module to add properties and functions to 307 | // the Typescript definition 308 | declare module '../generated/menu_button' { 309 | export interface MenuButton { 310 | accessLevel: number 311 | } 312 | } 313 | 314 | 315 | const restrictedPages: Array<[RegExp, number]> = [ 316 | [/^admin/, 9], 317 | ] 318 | 319 | // Define a javascript property which becomes the actual 320 | // property implementation 321 | Object.defineProperty(MenuButton.prototype, 'accessLevel', { 322 | get() { 323 | const slug: string = this.link && isEntry(this.link) ? 324 | this.link.slug : 325 | this.externalLink 326 | 327 | if (!slug) { 328 | return 0 329 | } 330 | 331 | for (const restriction of restrictedPages) { 332 | const test = restriction[0] 333 | const accessLevel = restriction[1] 334 | 335 | if (test.test(slug)) { 336 | return restriction[1] 337 | } 338 | 339 | return 0 340 | } 341 | }, 342 | enumerable: true, 343 | }) 344 | ``` 345 | 346 | And using it in your react component: 347 | 348 | ```tsx 349 | import { Menu } from './lib/contentful/generated' 350 | 351 | interface IProps { 352 | resolvedMenu: Menu, 353 | currentUser: { 354 | accessLevel: number 355 | } 356 | } 357 | 358 | export class MenuRenderer extends React.Component { 359 | 360 | public render() { 361 | const { resolvedMenu, currentUser } = this.props 362 | 363 | return
364 | { 365 | resolvedMenu.items 366 | // Here we only show the buttons that the current user has access to see. 367 | // Since `resolvedMenu` is an instance of Menu, its `items` field contains 368 | // only MenuButton instances, which have our property defined on them. 369 | .filter((btn) => currentUser.accessLevel >= btn.accessLevel) 370 | .map((btn) => ( 371 | 372 | 373 | {btn.text} 374 | 375 | )) 376 | } 377 |
378 | } 379 | } 380 | ``` 381 | 382 | This is a cleaner implementation than putting the access level logic in the view. 383 | --------------------------------------------------------------------------------