Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support typescript config #110

Merged
merged 10 commits into from Dec 15, 2022
37 changes: 33 additions & 4 deletions README.md
Expand Up @@ -30,12 +30,11 @@ Then, add the following to your `package.json`:

Feel free to change the output path to whatever you like.

Next, the codegen will expect you to have created a file called `getContentfulEnvironment.js` in the
root of your project directory, and it should export a promise that resolves with your Contentful
environment.
Next, the codegen will expect you to have created a file called either `getContentfulEnvironment.js` or `getContentfulEnvironment.ts`
in the root of your project directory, which should export a promise that resolves with your Contentful environment.

The reason for this is that you can do whatever you like to set up your Contentful Management
Client. Here's an example:
Client. Here's an example of a JavaScript config:

```js
const contentfulManagement = require("contentful-management")
Expand All @@ -51,6 +50,36 @@ module.exports = function() {
}
```

And the same example in TypeScript:

```ts
import { strict as assert } from "assert"
import contentfulManagement from "contentful-management"
import { EnvironmentGetter } from "contentful-typescript-codegen"

const { CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN, CONTENTFUL_SPACE_ID, CONTENTFUL_ENVIRONMENT } = process.env

assert(CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN)
assert(CONTENTFUL_SPACE_ID)
assert(CONTENTFUL_ENVIRONMENT)

const getContentfulEnvironment: EnvironmentGetter = () => {
const contentfulClient = contentfulManagement.createClient({
accessToken: CONTENTFUL_MANAGEMENT_API_ACCESS_TOKEN,
})

return contentfulClient
.getSpace(CONTENTFUL_SPACE_ID)
.then(space => space.getEnvironment(CONTENTFUL_ENVIRONMENT))
}

module.exports = getContentfulEnvironment
```

> **Note**
>
> `ts-node` must be installed to use a TypeScript config

### Command line options

```
Expand Down
9 changes: 8 additions & 1 deletion package.json
Expand Up @@ -34,7 +34,13 @@
"meow": "^9.0.0"
},
"peerDependencies": {
"prettier": ">= 1"
"prettier": ">= 1",
"ts-node": ">= 9.0.0"
},
"peerDependenciesMeta": {
"ts-node": {
"optional": true
}
},
"devDependencies": {
"@contentful/rich-text-types": "^13.4.0",
Expand All @@ -59,6 +65,7 @@
"rollup-plugin-typescript2": "^0.22.1",
"semantic-release": "^17.4.1",
"ts-jest": "^26.0.0",
"ts-node": "^10.6.0",
"tslint": "^5.18.0",
"tslint-config-prettier": "^1.18.0",
"tslint-config-standard": "^8.0.1",
Expand Down
9 changes: 4 additions & 5 deletions src/contentful-typescript-codegen.ts
Expand Up @@ -2,9 +2,12 @@ import render from "./renderers/render"
import renderFieldsOnly from "./renderers/renderFieldsOnly"
import path from "path"
import { outputFileSync } from "fs-extra"
import { loadEnvironment } from "./loadEnvironment"

const meow = require("meow")

export { ContentfulEnvironment, EnvironmentGetter } from "./loadEnvironment"

const cli = meow(
`
Usage
Expand Down Expand Up @@ -60,11 +63,7 @@ const cli = meow(
)

async function runCodegen(outputFile: string) {
const getEnvironmentPath = path.resolve(process.cwd(), "./getContentfulEnvironment.js")
const getEnvironment = require(getEnvironmentPath)
const environment = await getEnvironment()
const contentTypes = await environment.getContentTypes({ limit: 1000 })
const locales = await environment.getLocales()
const { contentTypes, locales } = await loadEnvironment()
const outputPath = path.resolve(process.cwd(), outputFile)

let output
Expand Down
78 changes: 78 additions & 0 deletions src/loadEnvironment.ts
@@ -0,0 +1,78 @@
import * as path from "path"
import * as fs from "fs"
import { ContentfulCollection, ContentTypeCollection, LocaleCollection } from "contentful"

// todo: switch to contentful-management interfaces here
export interface ContentfulEnvironment {
getContentTypes(options: { limit: number }): Promise<ContentfulCollection<unknown>>
getLocales(): Promise<ContentfulCollection<unknown>>
}

export type EnvironmentGetter = () => Promise<ContentfulEnvironment>

export async function loadEnvironment() {
try {
const getEnvironment = getEnvironmentGetter()
const environment = await getEnvironment()

return {
contentTypes: (await environment.getContentTypes({ limit: 1000 })) as ContentTypeCollection,
locales: (await environment.getLocales()) as LocaleCollection,
}
} finally {
if (registerer) {
registerer.enabled(false)
}
}
}

/* istanbul ignore next */
GabrielAnca marked this conversation as resolved.
Show resolved Hide resolved
const interopRequireDefault = (obj: any): { default: any } =>
obj && obj.__esModule ? obj : { default: obj }

type Registerer = { enabled(value: boolean): void }

let registerer: Registerer | null = null

function enableTSNodeRegisterer() {
if (registerer) {
registerer.enabled(true)

return
}

try {
registerer = require("ts-node").register() as Registerer
GabrielAnca marked this conversation as resolved.
Show resolved Hide resolved
registerer.enabled(true)
} catch (e) {
if (e.code === "MODULE_NOT_FOUND") {
throw new Error(
`'ts-node' is required for TypeScript configuration files. Make sure it is installed\nError: ${e.message}`,
)
}

throw e
}
}

function determineEnvironmentPath() {
const pathWithoutExtension = path.resolve(process.cwd(), "./getContentfulEnvironment")

if (fs.existsSync(`${pathWithoutExtension}.ts`)) {
return `${pathWithoutExtension}.ts`
}

return `${pathWithoutExtension}.js`
}

function getEnvironmentGetter(): EnvironmentGetter {
const getEnvironmentPath = determineEnvironmentPath()

if (getEnvironmentPath.endsWith(".ts")) {
enableTSNodeRegisterer()

return interopRequireDefault(require(getEnvironmentPath)).default
}

return require(getEnvironmentPath)
}
109 changes: 109 additions & 0 deletions test/loadEnvironment.test.ts
@@ -0,0 +1,109 @@
import * as fs from "fs"
import { loadEnvironment } from "../src/loadEnvironment"

const contentfulEnvironment = () => ({
getContentTypes: () => [],
getLocales: () => [],
})

const getContentfulEnvironmentFileFactory = jest.fn((_type: string) => contentfulEnvironment)

jest.mock(
require("path").resolve(process.cwd(), "./getContentfulEnvironment.js"),
() => getContentfulEnvironmentFileFactory("js"),
{ virtual: true },
)

jest.mock(
require("path").resolve(process.cwd(), "./getContentfulEnvironment.ts"),
() => getContentfulEnvironmentFileFactory("ts"),
{ virtual: true },
)

const tsNodeRegistererEnabled = jest.fn()
const tsNodeRegister = jest.fn()

jest.mock("ts-node", () => ({ register: tsNodeRegister }))

describe("loadEnvironment", () => {
beforeEach(() => {
jest.resetAllMocks()
jest.restoreAllMocks()
jest.resetModules()

getContentfulEnvironmentFileFactory.mockReturnValue(contentfulEnvironment)
tsNodeRegister.mockReturnValue({ enabled: tsNodeRegistererEnabled })
})

describe("when getContentfulEnvironment.ts exists", () => {
G-Rath marked this conversation as resolved.
Show resolved Hide resolved
beforeEach(() => {
jest.spyOn(fs, "existsSync").mockReturnValue(true)
})

describe("when ts-node is not found", () => {
beforeEach(() => {
// technically this is throwing after the `require` call,
// but it still tests the same code path so is fine
tsNodeRegister.mockImplementation(() => {
throw new (class extends Error {
public code: string

constructor(message?: string) {
super(message)
this.code = "MODULE_NOT_FOUND"
}
})()
})
})

it("throws a nice error", async () => {
await expect(loadEnvironment()).rejects.toThrow(
"'ts-node' is required for TypeScript configuration files",
)
})
})

describe("when there is another error", () => {
beforeEach(() => {
tsNodeRegister.mockImplementation(() => {
throw new Error("something else went wrong!")
})
})

it("re-throws", async () => {
await expect(loadEnvironment()).rejects.toThrow("something else went wrong!")
})
})

describe("when called multiple times", () => {
it("re-uses the registerer", async () => {
await loadEnvironment()
await loadEnvironment()

expect(tsNodeRegister).toHaveBeenCalledTimes(1)
})
})

it("requires the typescript config", async () => {
await loadEnvironment()

expect(getContentfulEnvironmentFileFactory).toHaveBeenCalledWith("ts")
expect(getContentfulEnvironmentFileFactory).not.toHaveBeenCalledWith("js")
})

it("disables the registerer afterwards", async () => {
await loadEnvironment()

expect(tsNodeRegistererEnabled).toHaveBeenCalledWith(false)
})
})

it("requires the javascript config", async () => {
jest.spyOn(fs, "existsSync").mockReturnValue(false)

await loadEnvironment()

expect(getContentfulEnvironmentFileFactory).toHaveBeenCalledWith("js")
expect(getContentfulEnvironmentFileFactory).not.toHaveBeenCalledWith("ts")
})
})