diff --git a/package.json b/package.json index 8d9da58..c9691cd 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,6 @@ } }, "engines": { - "node": ">=20" + "node": ">=20.12" } } diff --git a/src/adapters/index.ts b/src/adapters/index.ts index ea5a02b..8267d4e 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -9,6 +9,7 @@ import { glob } from "tinyglobby"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; +import { log } from "../lib/logger"; import { readPackageJson } from "../lib/packageJson"; import { appendTrailingSlash } from "../lib/url"; import { addRoute, removeRoute, updateRoute } from "../project"; @@ -110,6 +111,7 @@ export abstract class Adapter { const sliceDirectory = new URL(sliceDirectoryName, appendTrailingSlash(library)); const modelPath = new URL("model.json", appendTrailingSlash(sliceDirectory)); await writeFileRecursive(modelPath, stringify(model)); + log({ type: "file-created", url: modelPath }); await this.createSliceIndexFile(library); await this.onSliceCreated(model, library); } @@ -118,12 +120,14 @@ export abstract class Adapter { const slice = await this.getSlice(model.id); const modelPath = new URL("model.json", appendTrailingSlash(slice.directory)); await writeFileRecursive(modelPath, stringify(model)); + log({ type: "file-updated", url: modelPath }); await this.onSliceUpdated(model); } async deleteSlice(id: string): Promise { const slice = await this.getSlice(id); await rm(slice.directory, { recursive: true }); + log({ type: "file-deleted", url: slice.directory }); await this.createSliceIndexFile(slice.library); await this.onSliceDeleted(id); } @@ -162,6 +166,7 @@ export abstract class Adapter { const customTypesDirectory = new URL("customtypes/", projectRoot); const modelPath = new URL(`${model.id}/index.json`, customTypesDirectory); await writeFileRecursive(modelPath, stringify(model)); + log({ type: "file-created", url: modelPath }); if (model.format === "page") await addRoute(model); await this.onCustomTypeCreated(model); } @@ -170,6 +175,7 @@ export abstract class Adapter { const customType = await this.getCustomType(model.id); const modelPath = new URL("index.json", appendTrailingSlash(customType.directory)); await writeFileRecursive(modelPath, stringify(model)); + log({ type: "file-updated", url: modelPath }); await updateRoute(model); await this.onCustomTypeUpdated(model); } @@ -177,6 +183,7 @@ export abstract class Adapter { async deleteCustomType(id: string): Promise { const customType = await this.getCustomType(id); await rm(customType.directory, { recursive: true }); + log({ type: "file-deleted", url: customType.directory }); await removeRoute(id); await this.onCustomTypeDeleted(id); } @@ -187,11 +194,11 @@ export abstract class Adapter { host: string; }): Promise { const { repo, token, host } = config; - await Promise.all([ + const [syncSlicesResult, syncCustomTypesResult] = await Promise.all([ this.syncSlices({ repo, token, host, generateTypes: false }), this.syncCustomTypes({ repo, token, host, generateTypes: false }), ]); - await this.generateTypes(); + if (syncSlicesResult.didSync || syncCustomTypesResult.didSync) await this.generateTypes(); } async syncSlices(config: { @@ -199,31 +206,44 @@ export abstract class Adapter { token: string | undefined; host: string; generateTypes?: boolean; - }): Promise { + }): Promise<{ didSync: boolean }> { const { repo, token, host, generateTypes = true } = config; + let didSync = false; + const remoteSlices = await getSlices({ repo, token, host }); const localSlices = await this.getSlices(); // Handle slices update for (const remoteSlice of remoteSlices) { const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id); - if (localSlice) await this.updateSlice(remoteSlice); + if (localSlice && JSON.stringify(remoteSlice) !== JSON.stringify(localSlice.model)) { + await this.updateSlice(remoteSlice); + didSync = true; + } } // Handle slices deletion for (const localSlice of localSlices) { const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id); - if (!existsRemotely) await this.deleteSlice(localSlice.model.id); + if (!existsRemotely) { + await this.deleteSlice(localSlice.model.id); + didSync = true; + } } // Handle slices creation for (const remoteSlice of remoteSlices) { const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id); - if (!existsLocally) await this.createSlice(remoteSlice); + if (!existsLocally) { + await this.createSlice(remoteSlice); + didSync = true; + } } - if (generateTypes) await this.generateTypes(); + if (didSync && generateTypes) await this.generateTypes(); + + return { didSync }; } async syncCustomTypes(config: { @@ -231,9 +251,11 @@ export abstract class Adapter { token: string | undefined; host: string; generateTypes?: boolean; - }): Promise { + }): Promise<{ didSync: boolean }> { const { repo, token, host, generateTypes = true } = config; + let didSync = false; + const remoteCustomTypes = await getCustomTypes({ repo, token, host }); const localCustomTypes = await this.getCustomTypes(); @@ -242,7 +264,13 @@ export abstract class Adapter { const localCustomType = localCustomTypes.find( (customType) => customType.model.id === remoteCustomType.id, ); - if (localCustomType) await this.updateCustomType(remoteCustomType); + if ( + localCustomType && + JSON.stringify(remoteCustomType) !== JSON.stringify(localCustomType.model) + ) { + await this.updateCustomType(remoteCustomType); + didSync = true; + } } // Handle custom types deletion @@ -250,7 +278,10 @@ export abstract class Adapter { const existsRemotely = remoteCustomTypes.some( (customType) => customType.id === localCustomType.model.id, ); - if (!existsRemotely) await this.deleteCustomType(localCustomType.model.id); + if (!existsRemotely) { + await this.deleteCustomType(localCustomType.model.id); + didSync = true; + } } // Handle custom types creation @@ -258,10 +289,15 @@ export abstract class Adapter { const existsLocally = localCustomTypes.some( (customType) => customType.model.id === remoteCustomType.id, ); - if (!existsLocally) await this.createCustomType(remoteCustomType); + if (!existsLocally) { + await this.createCustomType(remoteCustomType); + didSync = true; + } } - if (generateTypes) await this.generateTypes(); + if (didSync && generateTypes) await this.generateTypes(); + + return { didSync }; } async generateTypes(): Promise { @@ -280,6 +316,7 @@ export abstract class Adapter { typesProvider: "@prismicio/client", }); await writeFileRecursive(output, types); + log({ type: "file-updated", url: output }); return output; } } diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index a445a41..3086551 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; +import { log } from "../lib/logger"; import { addDependencies, findPackageJson, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -52,6 +53,7 @@ export class NextJsAdapter extends Adapter { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(componentPath, contents); + log({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -91,6 +93,7 @@ export class NextJsAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); + log({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -116,6 +119,7 @@ async function createRevalidateRoute(): Promise { const contents = revalidateRouteTemplate({ supportsCacheLife }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createExitPreviewRoute(): Promise { @@ -132,6 +136,7 @@ async function createExitPreviewRoute(): Promise { const contents = exitPreviewRouteTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPreviewRoute(): Promise { @@ -148,6 +153,7 @@ async function createPreviewRoute(): Promise { const contents = previewRouteTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createSliceSimulatorPage(): Promise { @@ -164,6 +170,7 @@ async function createSliceSimulatorPage(): Promise { const contents = sliceSimulatorPageTemplate({ typescript, appRouter }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPrismicIoFile(): Promise { @@ -182,6 +189,7 @@ async function createPrismicIoFile(): Promise { hasSrcDirectory, }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPageFile(model: CustomType): Promise { @@ -206,6 +214,7 @@ async function createPageFile(model: CustomType): Promise { appRouter: usesAppRouter, }); await writeFileRecursive(pageFilePath, contents); + log({ type: "file-created", url: pageFilePath }); } async function checkUsesAppRouter() { diff --git a/src/adapters/nuxt.ts b/src/adapters/nuxt.ts index 050bbc8..34b34f6 100644 --- a/src/adapters/nuxt.ts +++ b/src/adapters/nuxt.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; +import { log } from "../lib/logger"; import { addDependencies, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -43,6 +44,7 @@ export class NuxtAdapter extends Adapter { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(componentPath, contents); + log({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -78,6 +80,7 @@ export class NuxtAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); + log({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -174,6 +177,7 @@ async function createSliceSimulatorPage(): Promise { const contents = sliceSimulatorPageTemplate({ typescript }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function moveOrDeleteAppVue(): Promise { @@ -194,9 +198,11 @@ async function moveOrDeleteAppVue(): Promise { if (!(await exists(indexVuePath))) { await writeFileRecursive(indexVuePath, contents); + log({ type: "file-created", url: indexVuePath }); } await rm(appVuePath); + log({ type: "file-deleted", url: appVuePath }); } async function modifySliceLibraryPath(adapter: NuxtAdapter): Promise { @@ -246,6 +252,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(pageFilePath, contents); + log({ type: "file-created", url: pageFilePath }); } async function getJsFileExtension(): Promise { diff --git a/src/adapters/sveltekit.ts b/src/adapters/sveltekit.ts index 65ca248..3e9d6f3 100644 --- a/src/adapters/sveltekit.ts +++ b/src/adapters/sveltekit.ts @@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url"; import { Adapter } from "."; import { exists, writeFileRecursive } from "../lib/file"; +import { log } from "../lib/logger"; import { addDependencies, findPackageJson, getNpmPackageVersion } from "../lib/packageJson"; import { dedent } from "../lib/string"; import { appendTrailingSlash } from "../lib/url"; @@ -55,6 +56,7 @@ export class SvelteKitAdapter extends Adapter { version: await getSvelteMajor(), }); await writeFileRecursive(componentPath, contents); + log({ type: "file-created", url: componentPath }); } onSliceUpdated(): void {} @@ -94,6 +96,7 @@ export class SvelteKitAdapter extends Adapter { const filename = `index.${extension}`; const indexPath = new URL(filename, library); await writeFileRecursive(indexPath, contents); + log({ type: "file-updated", url: indexPath }); } async getDefaultSliceLibrary(): Promise { @@ -111,6 +114,7 @@ async function createPrismicIoFile(): Promise { const typescript = await checkIsTypeScriptProject(); const contents = prismicIOFileTemplate({ typescript }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createSliceSimulatorPage(): Promise { @@ -122,6 +126,7 @@ async function createSliceSimulatorPage(): Promise { version: await getSvelteMajor(), }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPreviewRouteMatcher(): Promise { @@ -136,6 +141,7 @@ async function createPreviewRouteMatcher(): Promise { } `; await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPreviewAPIRoute(): Promise { @@ -147,6 +153,7 @@ async function createPreviewAPIRoute(): Promise { const typescript = await checkIsTypeScriptProject(); const contents = previewAPIRouteTemplate({ typescript }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPreviewRouteDirectory(): Promise { @@ -165,6 +172,7 @@ async function createPreviewRouteDirectory(): Promise { See for more information. `; await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createRootLayoutServerFile(): Promise { @@ -177,6 +185,7 @@ async function createRootLayoutServerFile(): Promise { export const prerender = "auto"; `; await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createRootLayoutFile(): Promise { @@ -188,6 +197,7 @@ async function createRootLayoutFile(): Promise { version: await getSvelteMajor(), }); await writeFileRecursive(filePath, contents); + log({ type: "file-created", url: filePath }); } async function createPageFile(model: CustomType): Promise { @@ -206,6 +216,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(pageFilePath, contents); + log({ type: "file-created", url: pageFilePath }); } const serverFilePath = new URL(`+page.server.${extension}`, fullRoutePath); @@ -215,6 +226,7 @@ async function createPageFile(model: CustomType): Promise { typescript: await checkIsTypeScriptProject(), }); await writeFileRecursive(serverFilePath, contents); + log({ type: "file-created", url: serverFilePath }); } } @@ -244,6 +256,7 @@ async function modifyViteConfig(): Promise { const contents = mod.generate().code.replace(/\n\s*\n(?=\s*server:)/, "\n"); await writeFile(configUrl, contents); + log({ type: "file-updated", url: configUrl }); } async function getJsFileExtension(): Promise { diff --git a/src/commands/gen-setup.ts b/src/commands/gen-setup.ts index e2223db..9ae4c19 100644 --- a/src/commands/gen-setup.ts +++ b/src/commands/gen-setup.ts @@ -1,6 +1,8 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { installDependencies } from "../lib/packageJson"; +import { findProjectRoot } from "../project"; const config = { name: "prismic gen setup", @@ -34,5 +36,8 @@ export default createCommand(config, async ({ values }) => { } } - console.info("Generated setup files."); + const projectRoot = await findProjectRoot(); + console.info( + formatChanges(flushLogs(), { title: "Generated setup files", root: projectRoot }), + ); }); diff --git a/src/commands/gen-types.ts b/src/commands/gen-types.ts index 6d9856b..c271747 100644 --- a/src/commands/gen-types.ts +++ b/src/commands/gen-types.ts @@ -1,6 +1,6 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; -import { relativePathname } from "../lib/url"; +import { flushLogs, formatChanges } from "../lib/logger"; import { findProjectRoot } from "../project"; const config = { @@ -10,10 +10,8 @@ const config = { export default createCommand(config, async () => { const adapter = await getAdapter(); - const typesPath = await adapter.generateTypes(); + await adapter.generateTypes(); const projectRoot = await findProjectRoot(); - const relativeOutput = relativePathname(projectRoot, typesPath); - - console.info(`Generated types at ${relativeOutput}`); + console.info(formatChanges(flushLogs(), { title: "Generated types", root: projectRoot })); }); diff --git a/src/commands/init.ts b/src/commands/init.ts index f403af1..6f96035 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,11 +6,13 @@ import { getProfile } from "../clients/user"; import { DEFAULT_PRISMIC_HOST } from "../env"; import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { installDependencies } from "../lib/packageJson"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { createConfig, deleteLegacySliceMachineConfig, + findProjectRoot, InvalidLegacySliceMachineConfigError, MissingPrismicConfigError, readConfig, @@ -152,5 +154,11 @@ export default createCommand(config, async ({ values }) => { // Sync models from remote and generate types await adapter.syncModels({ repo, token, host }); - console.info(`\nInitialized Prismic for repository "${repo}".`); + const projectRoot = await findProjectRoot(); + console.info( + formatChanges(flushLogs(), { + title: `Initialized Prismic for repository "${repo}"`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/slice-create.ts b/src/commands/slice-create.ts index c8f71a8..287143e 100644 --- a/src/commands/slice-create.ts +++ b/src/commands/slice-create.ts @@ -6,8 +6,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { insertSlice } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic slice create", @@ -55,9 +56,14 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - await adapter.createSlice(model); await adapter.generateTypes(); - console.info(`Created slice "${name}" (id: "${id}")`); + const projectRoot = await findProjectRoot(); + console.info( + formatChanges(flushLogs(), { + title: `Created slice "${name}" (ID: ${id})`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/slice-remove.ts b/src/commands/slice-remove.ts index 8a49da7..33c7675 100644 --- a/src/commands/slice-remove.ts +++ b/src/commands/slice-remove.ts @@ -2,8 +2,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getSlice, removeSlice } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic slice remove", @@ -34,11 +35,16 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - try { await adapter.deleteSlice(slice.id); } catch {} await adapter.generateTypes(); - console.info(`Slice removed: ${id}`); + const projectRoot = await findProjectRoot(); + console.info( + formatChanges(flushLogs(), { + title: `Removed slice "${slice.name}" (ID: ${slice.id})`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 74266ec..7f07731 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,9 +6,15 @@ import { getHost, getToken } from "../auth"; import { getCustomTypes, getSlices } from "../clients/custom-types"; import { env } from "../env"; import { createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { segmentTrackEnd, segmentTrackStart } from "../lib/segment"; import { dedent } from "../lib/string"; -import { checkIsTypeBuilderEnabled, getRepositoryName, TypeBuilderRequiredError } from "../project"; +import { + checkIsTypeBuilderEnabled, + findProjectRoot, + getRepositoryName, + TypeBuilderRequiredError, +} from "../project"; // 5 seconds balances responsiveness with API load const POLL_INTERVAL_MS = env.TEST ? 500 : 5000; @@ -53,24 +59,27 @@ export default createCommand(config, async ({ values }) => { await adapter.syncModels({ repo, token, host }); segmentTrackEnd("sync", { watch }); - console.info("Sync complete"); + const projectRoot = await findProjectRoot(); + console.info(formatChanges(flushLogs(), { title: "Sync complete", root: projectRoot })); } }); async function watchForChanges(repo: string, adapter: Adapter) { const token = await getToken(); const host = await getHost(); + const projectRoot = await findProjectRoot(); const initialRemoteSlices = await getSlices({ repo, token, host }); const initialRemoteCustomTypes = await getCustomTypes({ repo, token, host }); await adapter.syncModels({ repo, token, host }); + console.info(formatChanges(flushLogs(), { title: "Initial sync complete", root: projectRoot })); + console.info(dedent` - Initial sync completed! Watching for changes (polling every ${POLL_INTERVAL_MS / 1000}s), - Press Ctrl+C to stop\n + Press Ctrl+C to stop `); let lastRemoteSlicesHash = hash(initialRemoteSlices); @@ -116,7 +125,13 @@ async function watchForChanges(repo: string, adapter: Adapter) { await adapter.generateTypes(); const timestamp = new Date().toLocaleTimeString(); - console.info(`[${timestamp}] Changes detected in ${changed.join(" and ")}`); + console.info(); + console.info( + formatChanges(flushLogs(), { + title: `[${timestamp}] Changes detected in ${changed.join(" and ")}`, + root: projectRoot, + }), + ); } // Reset error count on success diff --git a/src/commands/type-create.ts b/src/commands/type-create.ts index db6f364..b726da4 100644 --- a/src/commands/type-create.ts +++ b/src/commands/type-create.ts @@ -6,8 +6,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { insertCustomType } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic type create", @@ -108,9 +109,14 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - await adapter.createCustomType(model); await adapter.generateTypes(); - console.info(`Created type "${name}" (id: "${id}", format: "${format}")`); + const projectRoot = await findProjectRoot(); + console.info( + formatChanges(flushLogs(), { + title: `Created type "${name}" (ID: ${id})`, + root: projectRoot, + }), + ); }); diff --git a/src/commands/type-remove.ts b/src/commands/type-remove.ts index 84257b4..a6752ee 100644 --- a/src/commands/type-remove.ts +++ b/src/commands/type-remove.ts @@ -2,8 +2,9 @@ import { getAdapter } from "../adapters"; import { getHost, getToken } from "../auth"; import { getCustomType, removeCustomType } from "../clients/custom-types"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { flushLogs, formatChanges } from "../lib/logger"; import { UnknownRequestError } from "../lib/request"; -import { getRepositoryName } from "../project"; +import { findProjectRoot, getRepositoryName } from "../project"; const config = { name: "prismic type remove", @@ -39,11 +40,16 @@ export default createCommand(config, async ({ positionals, values }) => { } throw error; } - try { await adapter.deleteCustomType(customType.id); } catch {} await adapter.generateTypes(); - console.info(`Type removed: ${id}`); + const projectRoot = await findProjectRoot(); + console.info( + formatChanges(flushLogs(), { + title: `Removed type "${customType.label}" (ID: ${customType.id})`, + root: projectRoot, + }), + ); }); diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..09f3f7f --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,77 @@ +import { styleText } from "node:util"; + +import { formatTable } from "./string"; +import { relativePathname } from "./url"; + +type Log = + | { type: "file-created"; url: URL } + | { type: "file-updated"; url: URL } + | { type: "file-deleted"; url: URL }; + +type Verb = "Created" | "Updated" | "Deleted"; + +const logs: Log[] = []; + +export function log(payload: Log): void { + logs.push(payload); +} + +export function flushLogs(): Log[] { + return logs.splice(0); +} + +const VERB_ORDER: Verb[] = ["Created", "Updated", "Deleted"]; + +const VERB_COLOR = { Created: "green", Updated: "blue", Deleted: "red" } as const; + +function getVerb(log: Log): Verb { + switch (log.type) { + case "file-created": + return "Created"; + case "file-updated": + return "Updated"; + case "file-deleted": + return "Deleted"; + } +} + +export function formatChanges(logs: Log[], config: { title: string; root?: URL }): string { + const { title, root } = config; + + const boldTitle = title; + + if (logs.length === 0) return boldTitle; + + const sorted = [...logs].sort( + (a, b) => VERB_ORDER.indexOf(getVerb(a)) - VERB_ORDER.indexOf(getVerb(b)), + ); + + const counts: Record = { Created: 0, Updated: 0, Deleted: 0 }; + for (const entry of logs) { + counts[getVerb(entry)]++; + } + + const parts: string[] = []; + for (const verb of VERB_ORDER) { + const count = counts[verb]; + if (count > 0) { + parts.push(`${count} ${count === 1 ? "file" : "files"} ${verb.toLowerCase()}`); + } + } + + const header = [boldTitle, "", styleText("dim", parts.join(", "))]; + + const rows = sorted.map((entry): [string, string] => { + const verb = getVerb(entry); + const coloredVerb = styleText(VERB_COLOR[verb], verb); + return [coloredVerb, root ? relativePathname(root, entry.url) : entry.url.href]; + }); + + const table = formatTable(rows, { separator: " " }); + const indented = table + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + + return [...header, indented].join("\n"); +} diff --git a/src/lib/string.ts b/src/lib/string.ts index bd8890d..b4cb706 100644 --- a/src/lib/string.ts +++ b/src/lib/string.ts @@ -2,6 +2,12 @@ import baseDedent from "dedent"; export const dedent = baseDedent.withOptions({ alignValues: true }); +const ANSI_RE = /\x1b\[[0-9;]*m/g; + +function visualLength(s: string): number { + return s.replace(ANSI_RE, "").length; +} + export function formatTable( rows: string[][], config?: { headers?: string[]; separator?: string }, @@ -11,13 +17,17 @@ export function formatTable( const columnWidths: number[] = []; for (const row of allRows) { for (let i = 0; i < row.length; i++) { - columnWidths[i] = Math.max(columnWidths[i] ?? 0, row[i].length); + columnWidths[i] = Math.max(columnWidths[i] ?? 0, visualLength(row[i])); } } return allRows .map((row) => { const line = row - .map((cell, i) => (i < row.length - 1 ? cell.padEnd(columnWidths[i]) : cell)) + .map((cell, i) => + i < row.length - 1 + ? cell + " ".repeat(columnWidths[i] - visualLength(cell)) + : cell, + ) .join(separator) .trimEnd(); return line; diff --git a/test/gen-types.test.ts b/test/gen-types.test.ts index 75b7188..dd67c29 100644 --- a/test/gen-types.test.ts +++ b/test/gen-types.test.ts @@ -21,7 +21,7 @@ it("generates types from local models", async ({ expect, project, prismic }) => const { exitCode, stdout } = await prismic("gen", ["types"]); expect(exitCode).toBe(0); - expect(stdout).toContain("Generated types"); + expect(stdout).toContain("Updated prismicio-types.d.ts"); await expect(project).toHaveFile("prismicio-types.d.ts", { contains: customType.id, @@ -31,7 +31,7 @@ it("generates types from local models", async ({ expect, project, prismic }) => it("generates types with no models", async ({ expect, project, prismic }) => { const { exitCode, stdout } = await prismic("gen", ["types"]); expect(exitCode).toBe(0); - expect(stdout).toContain("Generated types"); + expect(stdout).toContain("Updated prismicio-types.d.ts"); await expect(project).toHaveFile("prismicio-types.d.ts"); });