diff --git a/packages/adapter-next/package.json b/packages/adapter-next/package.json index 0ae7a5546c..8cf953cc44 100644 --- a/packages/adapter-next/package.json +++ b/packages/adapter-next/package.json @@ -63,6 +63,7 @@ "@prismicio/types-internal": "^2.2.0", "@slicemachine/plugin-kit": "workspace:^", "common-tags": "^1.8.2", + "dotenv": "16.3.1", "fp-ts": "^2.13.1", "io-ts": "^2.2.20", "io-ts-types": "^0.5.19", diff --git a/packages/adapter-next/src/constants.ts b/packages/adapter-next/src/constants.ts index 3f51e6635e..43e8a9ef07 100644 --- a/packages/adapter-next/src/constants.ts +++ b/packages/adapter-next/src/constants.ts @@ -3,3 +3,15 @@ */ export const NON_EDITABLE_FILE_BANNER = "// Code generated by Slice Machine. DO NOT EDIT."; + +/** + * The default file path at which environment variables will be stored. + */ +export const DEFAULT_ENVIRONMENT_VARIABLE_FILE_PATH = ".env.local"; + +/** + * The name of the environment variable that stores the active Prismic + * environment. + */ +export const PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME = + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT"; diff --git a/packages/adapter-next/src/hooks/project-environment-read.ts b/packages/adapter-next/src/hooks/project-environment-read.ts new file mode 100644 index 0000000000..15b86a676d --- /dev/null +++ b/packages/adapter-next/src/hooks/project-environment-read.ts @@ -0,0 +1,50 @@ +import type { ProjectEnvironmentReadHook } from "@slicemachine/plugin-kit"; +import { + checkHasProjectFile, + readProjectFile, +} from "@slicemachine/plugin-kit/fs"; +import * as dotenv from "dotenv"; + +import type { PluginOptions } from "../types"; +import { + DEFAULT_ENVIRONMENT_VARIABLE_FILE_PATH, + PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME, +} from "../constants"; + +export const projectEnvironmentRead: ProjectEnvironmentReadHook< + PluginOptions +> = async (_data, { options, helpers }) => { + const environmentVariableFilePath = + options.environmentVariableFilePath || + DEFAULT_ENVIRONMENT_VARIABLE_FILE_PATH; + + const hasEnvironmentVariableFile = await checkHasProjectFile({ + filename: environmentVariableFilePath, + helpers, + }); + + if (!hasEnvironmentVariableFile) { + return { + environment: undefined, + }; + } + + const contents = await readProjectFile({ + filename: environmentVariableFilePath, + helpers, + }); + + const vars = dotenv.parse(contents); + + const environment = vars[PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME]; + + if (environment) { + return { + environment, + }; + } + + return { + environment: undefined, + }; +}; diff --git a/packages/adapter-next/src/hooks/project-environment-update.ts b/packages/adapter-next/src/hooks/project-environment-update.ts new file mode 100644 index 0000000000..577ee98971 --- /dev/null +++ b/packages/adapter-next/src/hooks/project-environment-update.ts @@ -0,0 +1,124 @@ +import type { ProjectEnvironmentUpdateHook } from "@slicemachine/plugin-kit"; +import { + checkHasProjectFile, + readProjectFile, + writeProjectFile, +} from "@slicemachine/plugin-kit/fs"; + +import type { PluginOptions } from "../types"; +import { + DEFAULT_ENVIRONMENT_VARIABLE_FILE_PATH, + PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME, +} from "../constants"; + +type BuildVariableLineArgs = { + environment: string; +}; + +const buildVariableLine = (args: BuildVariableLineArgs): string => { + return `${PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME}=${args.environment}`; +}; + +type AppendEnvironmentVariableArgs = { + contents: string; + environment: string; +}; + +const appendEnvironmentVariable = ( + args: AppendEnvironmentVariableArgs, +): string => { + let res = args.contents.toString(); + + if (!res.endsWith("\n")) { + res += "\n"; + } + + res += buildVariableLine({ environment: args.environment }) + "\n"; + + return res; +}; + +type UpdateEnvironmentVariableArgs = { + contents: string; + environment: string; +}; + +const updateEnvironmentVariable = ( + args: UpdateEnvironmentVariableArgs, +): string => { + return args.contents.replace( + new RegExp(`^${PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME}=.*$\n?`), + `${PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME}=${args.environment}`, + ); +}; + +type RemoveEnvironmentVariableArgs = { + contents: string; +}; + +const removeEnvironmentVariable = ( + args: RemoveEnvironmentVariableArgs, +): string => { + return args.contents.replace( + new RegExp(`^${PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME}=.*$\n?`), + "", + ); +}; + +export const projectEnvironmentUpdate: ProjectEnvironmentUpdateHook< + PluginOptions +> = async ({ environment }, { options, helpers }) => { + const environmentVariableFilePath = + options.environmentVariableFilePath || + DEFAULT_ENVIRONMENT_VARIABLE_FILE_PATH; + + const variableRegExp = new RegExp( + `^${PRISMIC_ENVIRONMENT_ENVIRONMENT_VARIABLE_NAME}=.*$`, + "m", + ); + + const hasEnvironmentVariableFile = await checkHasProjectFile({ + filename: environmentVariableFilePath, + helpers, + }); + + let contents; + + if (hasEnvironmentVariableFile) { + const existingContents = await readProjectFile({ + filename: environmentVariableFilePath, + helpers, + encoding: "utf8", + }); + + const hasExistingVariable = variableRegExp.test(existingContents); + + if (environment === undefined) { + contents = removeEnvironmentVariable({ contents: existingContents }); + } else if (hasExistingVariable) { + contents = updateEnvironmentVariable({ + contents: existingContents, + environment, + }); + } else { + contents = appendEnvironmentVariable({ + contents: existingContents, + environment, + }); + } + } else { + if (environment === undefined) { + // noop + + return; + } + + contents = appendEnvironmentVariable({ contents: "", environment }); + } + + await writeProjectFile({ + filename: environmentVariableFilePath, + contents: contents, + helpers, + }); +}; diff --git a/packages/adapter-next/src/plugin.ts b/packages/adapter-next/src/plugin.ts index 8cfff0d220..27ec118332 100644 --- a/packages/adapter-next/src/plugin.ts +++ b/packages/adapter-next/src/plugin.ts @@ -31,6 +31,8 @@ import { PluginOptions } from "./types"; import { documentationRead } from "./hooks/documentation-read"; import { projectInit } from "./hooks/project-init"; +import { projectEnvironmentRead } from "./hooks/project-environment-read"; +import { projectEnvironmentUpdate } from "./hooks/project-environment-update"; import { sliceCreate } from "./hooks/slice-create"; import { sliceSimulatorSetupRead } from "./hooks/sliceSimulator-setup-read"; import { snippetRead } from "./hooks/snippet-read"; @@ -55,6 +57,8 @@ export const plugin = defineSliceMachinePlugin({ //////////////////////////////////////////////////////////////// hook("project:init", projectInit); + hook("project:environment:read", projectEnvironmentRead); + hook("project:environment:update", projectEnvironmentUpdate); //////////////////////////////////////////////////////////////// // slice:* diff --git a/packages/adapter-next/src/types.ts b/packages/adapter-next/src/types.ts index b017109769..1c3d23ffec 100644 --- a/packages/adapter-next/src/types.ts +++ b/packages/adapter-next/src/types.ts @@ -19,6 +19,14 @@ export type PluginOptions = { * @defaultValue `prismicio-types.d.ts` */ generatedTypesFilePath?: string; + + /** + * The filepath at which the active Prismic environment is stored as an + * environment variable. + * + * @defaultValue `.env.local` + */ + environmentVariableFilePath?: string; } & ( | { /** diff --git a/packages/adapter-next/test/plugin-project-environment-read.test.ts b/packages/adapter-next/test/plugin-project-environment-read.test.ts new file mode 100644 index 0000000000..b894638446 --- /dev/null +++ b/packages/adapter-next/test/plugin-project-environment-read.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "vitest"; +import { createSliceMachinePluginRunner } from "@slicemachine/plugin-kit"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import adapter from "../src"; + +test("returns the active environment", async (ctx) => { + await fs.writeFile( + path.join(ctx.project.root, ".env.local"), + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo", + ); + + const res = await ctx.pluginRunner.callHook( + "project:environment:read", + undefined, + ); + + expect(res.data[0].environment).toBe("foo"); +}); + +test("reads from the configured file path", async (ctx) => { + ctx.project.config.adapter.options.environmentVariableFilePath = ".bar"; + const pluginRunner = createSliceMachinePluginRunner({ + project: ctx.project, + nativePlugins: { + [ctx.project.config.adapter.resolve]: adapter, + }, + }); + await pluginRunner.init(); + + await fs.writeFile( + path.join(ctx.project.root, ".bar"), + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo", + ); + + const res = await pluginRunner.callHook( + "project:environment:read", + undefined, + ); + + expect(res.data[0].environment).toBe("foo"); +}); + +test("returns undefined if the env file does not exist", async (ctx) => { + const res = await ctx.pluginRunner.callHook( + "project:environment:read", + undefined, + ); + + expect(res.data[0].environment).toBe(undefined); +}); + +test("returns undefined if the env file does not contain the variable", async (ctx) => { + await fs.writeFile(path.join(ctx.project.root, ".env.local"), "FOO=bar"); + + const res = await ctx.pluginRunner.callHook( + "project:environment:read", + undefined, + ); + + expect(res.data[0].environment).toBe(undefined); +}); diff --git a/packages/adapter-next/test/plugin-project-environment-update.test.ts b/packages/adapter-next/test/plugin-project-environment-update.test.ts new file mode 100644 index 0000000000..eded6fcd25 --- /dev/null +++ b/packages/adapter-next/test/plugin-project-environment-update.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from "vitest"; +import { createSliceMachinePluginRunner } from "@slicemachine/plugin-kit"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import adapter from "../src"; + +test("writes the environment to default the env file", async (ctx) => { + await ctx.pluginRunner.callHook("project:environment:update", { + environment: "foo", + }); + + const contents = await fs.readFile( + path.join(ctx.project.root, ".env.local"), + "utf8", + ); + + expect(contents).toMatch(/^NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo$/m); +}); + +test("writes the environment to the configured env file", async (ctx) => { + ctx.project.config.adapter.options.environmentVariableFilePath = ".bar"; + const pluginRunner = createSliceMachinePluginRunner({ + project: ctx.project, + nativePlugins: { + [ctx.project.config.adapter.resolve]: adapter, + }, + }); + await pluginRunner.init(); + + await pluginRunner.callHook("project:environment:update", { + environment: "foo", + }); + + const contents = await fs.readFile( + path.join(ctx.project.root, ".bar"), + "utf8", + ); + + expect(contents).toMatch(/^NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo$/m); +}); + +test("updates the variable if the variable exists", async (ctx) => { + await fs.writeFile( + path.join(ctx.project.root, ".env.local"), + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo", + ); + + await ctx.pluginRunner.callHook("project:environment:update", { + environment: "bar", + }); + + const contents = await fs.readFile( + path.join(ctx.project.root, ".env.local"), + "utf8", + ); + + expect(contents).toMatch(/^NEXT_PUBLIC_PRISMIC_ENVIRONMENT=bar$/m); +}); + +test("appends the variable if other variables exist", async (ctx) => { + await fs.writeFile(path.join(ctx.project.root, ".env.local"), "FOO=bar"); + + await ctx.pluginRunner.callHook("project:environment:update", { + environment: "foo", + }); + + const contents = await fs.readFile( + path.join(ctx.project.root, ".env.local"), + "utf8", + ); + + expect(contents).toMatch(/^FOO=bar$/m); + expect(contents).toMatch(/^NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo$/m); +}); + +test("removes the variable if the environment is undefined", async (ctx) => { + await fs.writeFile( + path.join(ctx.project.root, ".env.local"), + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT=foo", + ); + + await ctx.pluginRunner.callHook("project:environment:update", { + environment: undefined, + }); + + const contents = await fs.readFile( + path.join(ctx.project.root, ".env.local"), + "utf8", + ); + + expect(contents).not.toMatch(/^NEXT_PUBLIC_PRISMIC_ENVIRONMENT=/m); +}); + +test("does nothing if the environment is undefined and the env file does not exist", async (ctx) => { + await ctx.pluginRunner.callHook("project:environment:update", { + environment: undefined, + }); + + expect( + fs.access(path.join(ctx.project.root, ".env.local")), + ).rejects.toThrow(); +}); diff --git a/yarn.lock b/yarn.lock index 5daab1a4fa..5d126d9b8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8105,6 +8105,7 @@ __metadata: "@vitest/coverage-v8": 0.32.0 common-tags: ^1.8.2 depcheck: 1.4.3 + dotenv: 16.3.1 eslint: 8.37.0 eslint-config-prettier: 8.7.0 eslint-plugin-prettier: 4.2.1 @@ -16700,6 +16701,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:16.3.1": + version: 16.3.1 + resolution: "dotenv@npm:16.3.1" + checksum: 15d75e7279018f4bafd0ee9706593dd14455ddb71b3bcba9c52574460b7ccaf67d5cf8b2c08a5af1a9da6db36c956a04a1192b101ee102a3e0cf8817bbcf3dfd + languageName: node + linkType: hard + "dotenv@npm:^16.0.0, dotenv@npm:^16.0.3": version: 16.1.4 resolution: "dotenv@npm:16.1.4"