diff --git a/package.json b/package.json index d9cc4c1..18775dc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,10 @@ "./dist" ], "type": "module", + "exports": { + "./env/register": "./dist/env/register.mjs", + "./package.json": "./package.json" + }, "publishConfig": { "access": "public" }, diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4e3fc7d..0727b8d 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,14 +1,14 @@ import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; import { pascalCase } from "change-case"; -import { rm } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { generateTypes } from "prismic-ts-codegen"; import { glob } from "tinyglobby"; -import { readJsonFile, writeFileRecursive } from "../lib/file"; +import { findFirstFile, readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; -import { readPackageJson } from "../lib/packageJson"; +import { addDependencies, getNpmPackageVersion, readPackageJson } from "../lib/packageJson"; import { appendTrailingSlash } from "../lib/url"; import { addRoute, removeRoute, updateRoute } from "../project"; import { findProjectRoot, getLibraries } from "../project"; @@ -206,3 +206,29 @@ export abstract class Adapter { return output; } } + +export async function addEnvRegisterImport(configFilenames: string[]): Promise { + const projectRoot = await findProjectRoot(); + const configUrl = await findFirstFile( + configFilenames.map((filename) => new URL(filename, projectRoot)), + ); + if (!configUrl) return; + + // The register module uses top-level await, so it can only be + // imported from an ESM config file. + let isEsm = configUrl.pathname.endsWith(".mjs") || configUrl.pathname.endsWith(".ts"); + if (!isEsm) { + const packageJson = await readPackageJson(); + isEsm = packageJson.type === "module"; + } + if (!isEsm) return; + + const statement = 'import "prismic/env/register";'; + const contents = await readFile(configUrl, "utf8"); + if (contents.includes(statement)) return; + await writeFile(configUrl, `${statement}\n\n${contents}`); + + // The config now imports `prismic/env/register`, so the project needs + // `prismic` installed to resolve it. + await addDependencies({ prismic: `^${await getNpmPackageVersion("prismic")}` }); +} diff --git a/src/adapters/nextjs.templates.ts b/src/adapters/nextjs.templates.ts index fce35bc..3cac94b 100644 --- a/src/adapters/nextjs.templates.ts +++ b/src/adapters/nextjs.templates.ts @@ -357,7 +357,8 @@ export function prismicIOFileTemplate(args: { /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = + process.env.NEXT_PUBLIC_PRISMIC_ENVIRONMENT ?? prismicConfig.repositoryName; ${createClientContents} `; @@ -369,7 +370,8 @@ export function prismicIOFileTemplate(args: { /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = + process.env.NEXT_PUBLIC_PRISMIC_ENVIRONMENT ?? prismicConfig.repositoryName; ${createClientContents} `; diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index c21d83b..937459d 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -5,7 +5,7 @@ import { createRequire } from "node:module"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter } from "."; +import { Adapter, addEnvRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -38,6 +38,7 @@ export class NextJsAdapter extends Adapter { await createPreviewRoute(); await createExitPreviewRoute(); await createRevalidateRoute(); + await addEnvRegisterImport(["next.config.mjs", "next.config.ts", "next.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index 7d358f4..d3c7a3a 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -6,7 +6,7 @@ import { readFile, rm } from "node:fs/promises"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter } from "."; +import { Adapter, addEnvRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -31,6 +31,7 @@ export class NuxtAdapter extends Adapter { await createSliceSimulatorPage(); await moveOrDeleteAppVue(); await modifySliceLibraryPath(this); + await addEnvRegisterImport(["nuxt.config.ts", "nuxt.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/adapters/sveltekit.templates.ts b/src/adapters/sveltekit.templates.ts index 9a07509..3269918 100644 --- a/src/adapters/sveltekit.templates.ts +++ b/src/adapters/sveltekit.templates.ts @@ -11,12 +11,14 @@ export function prismicIOFileTemplate(args: { typescript: boolean }): string { return dedent` import { createClient as baseCreateClient } from "@prismicio/client"; import { type CreateClientConfig, enableAutoPreviews } from '@prismicio/svelte/kit'; + import { env } from '$env/dynamic/public'; import prismicConfig from "../../prismic.config.json"; /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = + env.PUBLIC_PRISMIC_ENVIRONMENT ?? prismicConfig.repositoryName; /** * Creates a Prismic client for the project's repository. The client is used to @@ -40,12 +42,14 @@ export function prismicIOFileTemplate(args: { typescript: boolean }): string { return dedent` import { createClient as baseCreateClient } from "@prismicio/client"; import { enableAutoPreviews } from '@prismicio/svelte/kit'; + import { env } from '$env/dynamic/public'; import prismicConfig from "../../prismic.config.json"; /** * The project's Prismic repository name. */ - export const repositoryName = prismicConfig.repositoryName; + export const repositoryName = + env.PUBLIC_PRISMIC_ENVIRONMENT ?? prismicConfig.repositoryName; /** * Creates a Prismic client for the project's repository. The client is used to diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index d200626..9436417 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -7,7 +7,7 @@ import { createRequire } from "node:module"; import { relative } from "node:path"; import { fileURLToPath } from "node:url"; -import { Adapter } from "."; +import { Adapter, addEnvRegisterImport } from "."; import { getHost, getToken } from "../auth"; import { addPreview, getPreviews, getSimulatorUrl, setSimulatorUrl } from "../clients/core"; import { exists, writeFileRecursive } from "../lib/file"; @@ -42,6 +42,7 @@ export class SvelteKitAdapter extends Adapter { await createRootLayoutServerFile(); await createRootLayoutFile(); await modifyViteConfig(); + await addEnvRegisterImport(["vite.config.ts", "vite.config.js"]); } async onProjectInitialized(): Promise { diff --git a/src/environments.ts b/src/environments.ts index 3ed8a75..033846c 100644 --- a/src/environments.ts +++ b/src/environments.ts @@ -9,7 +9,7 @@ import { stringify } from "./lib/json"; import { findProjectRoot } from "./project"; const EnvironmentsSchema = z.partialRecord(z.string(), z.string()); -type Environments = z.infer; +export type Environments = z.infer; export async function getEnvironment(): Promise { const projectRoot = await findProjectRoot(); diff --git a/src/exports/env/register.ts b/src/exports/env/register.ts new file mode 100644 index 0000000..f00a931 --- /dev/null +++ b/src/exports/env/register.ts @@ -0,0 +1,44 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import type { Environments } from "../../environments"; + +import { ENVIRONMENTS_PATH } from "../../config"; +import { appendTrailingSlash } from "../../lib/url"; + +const environment = getEnvironment(); +if (environment) { + process.env.NEXT_PUBLIC_PRISMIC_ENVIRONMENT ??= environment; + process.env.PUBLIC_PRISMIC_ENVIRONMENT ??= environment; + process.env.NUXT_PUBLIC_PRISMIC_ENVIRONMENT ??= environment; +} + +// Reads the active environment synchronously so this module can run without +// top-level `await`. Bundlers that load config files (e.g. Next.js loading +// `next.config.ts`) don't support top-level `await` and fail otherwise. +function getEnvironment(): string | undefined { + const projectRoot = findProjectRoot(); + if (!projectRoot) return; + try { + const contents = readFileSync(ENVIRONMENTS_PATH, "utf-8"); + const environments: Environments = JSON.parse(contents); + return environments[projectRoot.toString()]; + } catch { + return; + } +} + +function findProjectRoot(): URL | undefined { + let dir = appendTrailingSlash(pathToFileURL(process.cwd())); + while (true) { + if ( + existsSync(new URL("prismic.config.json", dir)) || + existsSync(new URL("package.json", dir)) + ) { + return appendTrailingSlash(pathToFileURL(realpathSync(fileURLToPath(dir)))); + } + const parent = new URL("..", dir); + if (parent.href === dir.href) return; + dir = parent; + } +} diff --git a/src/lib/file.ts b/src/lib/file.ts index bf64473..d4551ce 100644 --- a/src/lib/file.ts +++ b/src/lib/file.ts @@ -49,6 +49,12 @@ export async function exists(path: URL): Promise { } } +export async function findFirstFile(candidates: URL[]): Promise { + for (const candidate of candidates) { + if (await exists(candidate)) return candidate; + } +} + export async function writeFileRecursive( path: URL, data: Parameters[1], diff --git a/src/lib/packageJson.ts b/src/lib/packageJson.ts index 9b71f11..296cdd2 100644 --- a/src/lib/packageJson.ts +++ b/src/lib/packageJson.ts @@ -12,6 +12,7 @@ const PackageJsonSchema = z.object({ devDependencies: z.optional(z.record(z.string(), z.string())), peerDependencies: z.optional(z.record(z.string(), z.string())), packageManager: z.optional(z.string()), + type: z.optional(z.string()), }); type PackageJson = z.infer; @@ -75,6 +76,31 @@ const INSTALL_COMMANDS = { bun: ["bun", "install"], }; +const ADD_COMMANDS = { + npm: "npm install", + yarn: "yarn add", + pnpm: "pnpm add", + bun: "bun add", +}; + +// Returns a package-manager-specific command to update a dependency to its +// latest version, or undefined if the package is not a project dependency. +export async function getUpdateCommand(name: string): Promise { + let packageJson: PackageJson; + try { + packageJson = await readPackageJson(); + } catch { + return undefined; + } + const installed = + packageJson.dependencies?.[name] ?? + packageJson.devDependencies?.[name] ?? + packageJson.peerDependencies?.[name]; + if (!installed) return undefined; + const packageManager = await detectPackageManager(); + return `${ADD_COMMANDS[packageManager]} ${name}@latest`; +} + export async function installDependencies(): Promise { const packageJsonPath = await findPackageJson(); const cwd = new URL(".", packageJsonPath); diff --git a/src/lib/update-notifier.ts b/src/lib/update-notifier.ts index fbd4ee5..5fc7cc8 100644 --- a/src/lib/update-notifier.ts +++ b/src/lib/update-notifier.ts @@ -6,7 +6,7 @@ import * as z from "zod/mini"; import packageJson from "../../package.json" with { type: "json" }; import { stringify } from "./json"; -import { getNpmPackageVersion } from "./packageJson"; +import { getNpmPackageVersion, getUpdateCommand } from "./packageJson"; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -29,7 +29,14 @@ export async function initUpdateNotifier(options: UpdateNotifierOptions): Promis const currentVersion = packageJson.version; if (state?.latestKnownVersion && isNewer(state.latestKnownVersion, currentVersion)) { - const message = `Update available: ${currentVersion} → ${state.latestKnownVersion}. Run \`npx ${options.npmPackageName}@latest --version\` to update.`; + // When `prismic` is a project dependency, `npx` would fetch a fresh + // copy instead of updating the installed one, so point the user at + // their package manager instead. + const updateCommand = await getUpdateCommand(options.npmPackageName); + const instruction = updateCommand + ? `Run \`${updateCommand}\` to update.` + : `Run \`npx ${options.npmPackageName}@latest --version\` to update.`; + const message = `Update available: ${currentVersion} → ${state.latestKnownVersion}. ${instruction}`; process.on("exit", () => { try { console.error(`\n${message}`); diff --git a/test/env-register.serial.test.ts b/test/env-register.serial.test.ts new file mode 100644 index 0000000..366f5d5 --- /dev/null +++ b/test/env-register.serial.test.ts @@ -0,0 +1,34 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { vi } from "vitest"; + +import { it } from "./it"; + +const REGISTER = fileURLToPath(new URL("../dist/env/register.mjs", import.meta.url)); + +const ENV_VARS = [ + "NEXT_PUBLIC_PRISMIC_ENVIRONMENT", + "PUBLIC_PRISMIC_ENVIRONMENT", + "NUXT_PUBLIC_PRISMIC_ENVIRONMENT", +] as const; + +it("does not set env vars when no environment is configured", async ({ expect, home, project }) => { + process.chdir(fileURLToPath(project)); + vi.stubEnv("PRISMIC_CONFIG_DIR", fileURLToPath(new URL(".config/prismic/", home))); + vi.resetModules(); + await import(REGISTER); + for (const key of ENV_VARS) expect(process.env[key]).toBeUndefined(); +}); + +it("sets env vars when an environment is configured", async ({ expect, home, project }) => { + process.chdir(fileURLToPath(project)); + vi.stubEnv("PRISMIC_CONFIG_DIR", fileURLToPath(new URL(".config/prismic/", home))); + await mkdir(new URL(".config/prismic/", home), { recursive: true }); + await writeFile( + new URL(".config/prismic/environments.json", home), + JSON.stringify({ [project.toString()]: "my-stage-env" }), + ); + vi.resetModules(); + await import(REGISTER); + for (const key of ENV_VARS) expect(process.env[key]).toBe("my-stage-env"); +}); diff --git a/test/gen-setup.test.ts b/test/gen-setup.test.ts index 113e0a5..fae6f12 100644 --- a/test/gen-setup.test.ts +++ b/test/gen-setup.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { it } from "./it"; @@ -61,6 +61,41 @@ it("generates valid script tags for SvelteKit", { timeout: 30_000 }, async ({ }); }); +it("adds the env register import to the framework config", { timeout: 30_000 }, async ({ + expect, + project, + prismic, +}) => { + const configPath = new URL("next.config.mjs", project); + await writeFile(configPath, "export default {};\n"); + + const { exitCode } = await prismic("gen", ["setup", "--no-install"]); + expect(exitCode).toBe(0); + + const contents = await readFile(configPath, "utf8"); + expect(contents).toBe('import "prismic/env/register";\n\nexport default {};\n'); + + // The import needs `prismic` installed to resolve, so it is added as a dependency. + const packageJson = JSON.parse(await readFile(new URL("package.json", project), "utf8")); + expect(packageJson.dependencies).toHaveProperty("prismic"); +}); + +it("skips the env register import for a CommonJS config", { timeout: 30_000 }, async ({ + expect, + project, + prismic, +}) => { + // The fixture's package.json has no "type": "module", so a .js config is CommonJS. + const configPath = new URL("next.config.js", project); + await writeFile(configPath, "module.exports = {};\n"); + + const { exitCode } = await prismic("gen", ["setup", "--no-install"]); + expect(exitCode).toBe(0); + + const contents = await readFile(configPath, "utf8"); + expect(contents).toBe("module.exports = {};\n"); +}); + it("skips installation with --no-install", async ({ expect, project, prismic }) => { const { exitCode, stdout } = await prismic("gen", ["setup", "--no-install"]); expect(exitCode).toBe(0); diff --git a/tsdown.config.ts b/tsdown.config.ts index 241d2f1..a3673d6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -6,6 +6,7 @@ const TEST = MODE === "test"; export default defineConfig({ entry: { index: "./src/index.ts", + "env/register": "./src/exports/env/register.ts", "subprocesses/refreshToken": "./src/subprocesses/refreshToken.ts", "subprocesses/sendSegmentEvents": "./src/subprocesses/sendSegmentEvents.ts", "subprocesses/updateVersionState": "./src/subprocesses/updateVersionState.ts",