Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"plugins": [
"simple-import-sort"
],
"ignorePatterns": [
"src/lib/preview-server/client/**"
],
"rules": {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@
"@oclif/plugin-help": "^6",
"@prantlf/jsonlint": "^14.1.0",
"axios": "^1.13.2",
"chokidar": "^5.0.0",
"date-fns": "^2.30.0",
"degit": "^2.8.4",
"enquirer": "^2.4.1",
"express": "^5.2.1",
"find-up": "^5.0.0",
"fs-extra": "^11.3.3",
"liquidjs": "^10.24.0",
"locale-codes": "^1.3.1",
"lodash": "^4.17.21",
"open": "8.4.2",
"quicktype-core": "^23.2.6",
"ws": "^8.19.0",
"zod": "^4.3.5"
},
"devDependencies": {
Expand All @@ -41,9 +44,11 @@
"@swc/helpers": "^0.5.18",
"@types/chai": "^4",
"@types/degit": "^2.8.6",
"@types/express": "^5.0.6",
"@types/fs-extra": "^11.0.4",
"@types/mocha": "^10.0.10",
"@types/node": "^20.19.28",
"@types/ws": "^8.18.1",
"chai": "^4",
"eslint": "^7.32.0",
"eslint-config-oclif": "^4",
Expand Down Expand Up @@ -101,7 +106,8 @@
}
},
"scripts": {
"build": "shx rm -rf dist && swc src -d dist --strip-leading-paths",
"build": "shx rm -rf dist && swc src -d dist --strip-leading-paths --ignore '**/preview-server/client/**' && yarn build:preview-client",
"build:preview-client": "cd src/lib/preview-server/client && yarn install --frozen-lockfile && yarn build",
"lint": "eslint . --ext .ts --config .eslintrc.json",
"lint.fix": "yarn run lint --fix",
"format": "prettier \"src/**/*.+(ts|js)\" \"test/**/*.+(ts|js)\"",
Expand Down
184 changes: 184 additions & 0 deletions src/commands/workflow/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import * as path from "node:path";

import { Args, Flags } from "@oclif/core";

import BaseCommand from "@/lib/base-command";
import * as CustomFlags from "@/lib/helpers/flag";
import { pathExists } from "@/lib/helpers/fs";
import { readJson } from "@/lib/helpers/json";
import { resolveResourceDir } from "@/lib/helpers/project-config";
import * as Workflow from "@/lib/marshal/workflow";
import { createPreviewServer } from "@/lib/preview-server";
import { WorkflowDirContext } from "@/lib/run-context";

export default class WorkflowPreview extends BaseCommand<
typeof WorkflowPreview
> {
static summary = "Start a local preview server for workflow templates.";

static description = `
Starts a local development server that allows you to preview and edit workflow
templates in your browser. The server watches for file changes and automatically
reloads the preview.

Currently supports email channel templates with HTML/text toggle and responsive
preview modes.
`;

static examples = [
{
command: "<%= config.bin %> <%= command.id %> my-workflow",
description: "Start preview server for a workflow",
},
{
command:
"<%= config.bin %> <%= command.id %> my-workflow --data-file ./sample-data.json",
description: "Start with sample trigger data from a file",
},
{
command: "<%= config.bin %> <%= command.id %> my-workflow --port 4000",
description: "Start on a custom port",
},
];

static flags = {
environment: Flags.string({
default: "development",
summary: "The environment to use for template preview.",
}),
branch: CustomFlags.branch,
"data-file": Flags.string({
summary: "Path to a JSON file containing sample trigger data.",
}),
port: Flags.integer({
default: 3004,
summary: "Port to run the preview server on.",
}),
};

static args = {
workflowKey: Args.string({
required: true,
description: "The workflow key to preview.",
}),
};

async run(): Promise<void> {
const { args, flags } = this.props;

// Resolve the workflow directory
const workflowDirCtx = await this.resolveWorkflowDir(args.workflowKey);

if (!workflowDirCtx.exists) {
this.error(
`Cannot locate workflow directory at \`${workflowDirCtx.abspath}\``,
);
}

// Read the workflow to validate it exists
const [workflow, readErrors] = await Workflow.readWorkflowDir(
workflowDirCtx,
{ withExtractedFiles: true },
);

if (readErrors.length > 0 || !workflow) {
this.error(`Failed to read workflow: ${readErrors[0]?.message}`);
}

// Load sample data from file if provided
let sampleData: Record<string, unknown> | undefined;
if (flags["data-file"]) {
const dataFilePath = path.resolve(process.cwd(), flags["data-file"]);
if (!(await pathExists(dataFilePath))) {
this.error(`Data file not found: ${dataFilePath}`);
}

const [data, dataErrors] = await readJson(dataFilePath);
if (dataErrors.length > 0 || !data) {
this.error(`Failed to read data file: ${dataErrors[0]?.message}`);
}

sampleData = data as Record<string, unknown>;
}

// Resolve layouts and partials directories
const layoutsDir = await resolveResourceDir(
this.projectConfig,
"email_layout",
this.runContext.cwd,
);
const partialsDir = await resolveResourceDir(
this.projectConfig,
"partial",
this.runContext.cwd,
);

this.log(`‣ Starting preview server for workflow \`${args.workflowKey}\``);
this.log(` Workflow directory: ${workflowDirCtx.abspath}`);
this.log(` Port: ${flags.port}`);
this.log("");

// Start the preview server
const server = await createPreviewServer({
workflowDirCtx,
layoutsDir,
partialsDir,
sampleData,
port: flags.port,
apiClient: this.apiV1,
environment: flags.environment,
branch: flags.branch,
});

this.log(`‣ Preview server running at http://localhost:${flags.port}`);
this.log(" Press Ctrl+C to stop");
this.log("");

// Keep the process running until interrupted
await new Promise<void>((resolve) => {
const cleanup = () => {
this.log("\n‣ Shutting down preview server...");
server.close(() => {
this.log(" Server stopped.");
resolve();
});
};

process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
}

private async resolveWorkflowDir(
workflowKey: string,
): Promise<WorkflowDirContext> {
const { resourceDir: resourceDirCtx, cwd: runCwd } = this.runContext;

// If we're inside a workflow directory, use it
if (resourceDirCtx?.type === "workflow") {
if (resourceDirCtx.key !== workflowKey) {
this.error(
`Cannot preview \`${workflowKey}\` inside another workflow directory:\n${resourceDirCtx.key}`,
);
}

return resourceDirCtx;
}

// Otherwise, resolve the workflows index directory
const workflowsIndexDir = await resolveResourceDir(
this.projectConfig,
"workflow",
runCwd,
);

const targetDirPath = path.resolve(workflowsIndexDir.abspath, workflowKey);

return {
type: "workflow",
key: workflowKey,
abspath: targetDirPath,
exists: await Workflow.isWorkflowDir(targetDirPath),
};
}
}
7 changes: 7 additions & 0 deletions src/lib/helpers/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ export type DirContext = {
exists: boolean;
};

/*
* Check if a given file path exists.
*/
export const pathExists = async (abspath: string): Promise<boolean> => {
return fs.pathExists(abspath);
};

/*
* Check if a given file path is a directory.
*/
Expand Down
Loading