Skip to content

Commit eec0f18

Browse files
committed
mike-kno-9713-cli-add-reusable-step-marhsal-logic
1 parent 7df538f commit eec0f18

File tree

17 files changed

+1152
-7
lines changed

17 files changed

+1152
-7
lines changed

src/lib/marshal/index.isomorphic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export { buildEmailLayoutDirBundle } from "./email-layout/processor.isomorphic";
55
export { buildGuideDirBundle } from "./guide/processor.isomorphic";
66
export { buildMessageTypeDirBundle } from "./message-type/processor.isomorphic";
77
export { buildPartialDirBundle } from "./partial/processor.isomorphic";
8+
export { buildReusableStepDirBundle } from "./reusable-step/processor.isomorphic";
89
export { buildTranslationDirBundle } from "./translation/processor.isomorphic";
910
export { buildWorkflowDirBundle } from "./workflow/processor.isomorphic";
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import * as path from "node:path";
2+
3+
import { ux } from "@oclif/core";
4+
import * as fs from "fs-extra";
5+
6+
import { DirContext } from "@/lib/helpers/fs";
7+
import { ReusableStepDirContext, RunContext } from "@/lib/run-context";
8+
9+
import { REUSABLE_STEP_JSON } from "./processor.isomorphic";
10+
11+
export const reusableStepJsonPath = (
12+
reusableStepDirCtx: ReusableStepDirContext,
13+
): string => path.resolve(reusableStepDirCtx.abspath, REUSABLE_STEP_JSON);
14+
15+
/*
16+
* Evaluates whether the given directory path is a reusable step directory, by
17+
* checking for the presence of a `reusable-step.json` file.
18+
*/
19+
export const isReusableStepDir = async (dirPath: string): Promise<boolean> =>
20+
Boolean(await lsReusableStepJson(dirPath));
21+
22+
/*
23+
* Check for `reusable-step.json` file and return the file path if present.
24+
*/
25+
export const lsReusableStepJson = async (
26+
dirPath: string,
27+
): Promise<string | undefined> => {
28+
const reusableStepJsonPath = path.resolve(dirPath, REUSABLE_STEP_JSON);
29+
30+
const exists = await fs.pathExists(reusableStepJsonPath);
31+
return exists ? reusableStepJsonPath : undefined;
32+
};
33+
34+
/*
35+
* Validate the provided args and flags with the current run context, to first
36+
* ensure the invoked command makes sense, and return the target context.
37+
*/
38+
type CommandTargetProps = {
39+
flags: {
40+
all: boolean | undefined;
41+
"reusable-steps-dir": DirContext | undefined;
42+
};
43+
args: {
44+
reusableStepKey: string | undefined;
45+
};
46+
};
47+
48+
type ReusableStepDirTarget = {
49+
type: "reusableStepDir";
50+
context: ReusableStepDirContext;
51+
};
52+
53+
type ReusableStepsIndexDirTarget = {
54+
type: "reusableStepsIndexDir";
55+
context: DirContext;
56+
};
57+
58+
export type ReusableStepCommandTarget =
59+
| ReusableStepDirTarget
60+
| ReusableStepsIndexDirTarget;
61+
62+
export const ensureValidCommandTarget = async (
63+
props: CommandTargetProps,
64+
runContext: RunContext,
65+
): Promise<ReusableStepCommandTarget> => {
66+
const { args, flags } = props;
67+
const { commandId, resourceDir: resourceDirCtx, cwd: runCwd } = runContext;
68+
69+
// If the target resource is a different type than the current resource dir
70+
// type, error out.
71+
if (resourceDirCtx && resourceDirCtx.type !== "reusable_step") {
72+
return ux.error(
73+
`Cannot run ${commandId} inside a ${resourceDirCtx.type} directory`,
74+
);
75+
}
76+
77+
// Cannot accept both reusable step key arg and --all flag.
78+
if (flags.all && args.reusableStepKey) {
79+
return ux.error(
80+
`reusableStepKey arg \`${args.reusableStepKey}\` cannot also be provided when using --all`,
81+
);
82+
}
83+
84+
// --all flag is given, which means no reusable step key arg.
85+
if (flags.all) {
86+
// If --all flag used inside a reusable step directory, then require a reusable steps
87+
// dir path.
88+
if (resourceDirCtx && !flags["reusable-steps-dir"]) {
89+
return ux.error("Missing required flag reusable-steps-dir");
90+
}
91+
92+
// Targeting all reusable step dirs in the reusable steps index dir.
93+
// TODO: Default to the knock project config first if present before cwd.
94+
const defaultToCwd = { abspath: runCwd, exists: true };
95+
const indexDirCtx = flags["reusable-steps-dir"] || defaultToCwd;
96+
97+
return { type: "reusableStepsIndexDir", context: indexDirCtx };
98+
}
99+
100+
// Reusable step key arg is given, which means no --all flag.
101+
if (args.reusableStepKey) {
102+
if (resourceDirCtx && resourceDirCtx.key !== args.reusableStepKey) {
103+
return ux.error(
104+
`Cannot run ${commandId} \`${args.reusableStepKey}\` inside another reusable step directory:\n${resourceDirCtx.key}`,
105+
);
106+
}
107+
108+
const targetDirPath = resourceDirCtx
109+
? resourceDirCtx.abspath
110+
: path.resolve(runCwd, args.reusableStepKey);
111+
112+
const reusableStepDirCtx: ReusableStepDirContext = {
113+
type: "reusable_step",
114+
key: args.reusableStepKey,
115+
abspath: targetDirPath,
116+
exists: await isReusableStepDir(targetDirPath),
117+
};
118+
119+
return { type: "reusableStepDir", context: reusableStepDirCtx };
120+
}
121+
122+
// From this point on, we have neither a reusable step key arg nor --all flag.
123+
// If running inside a reusable step directory, then use that.
124+
if (resourceDirCtx) {
125+
return { type: "reusableStepDir", context: resourceDirCtx };
126+
}
127+
128+
return ux.error("Missing 1 required arg:\nreusableStepKey");
129+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from "./helpers";
2+
export * from "./processor.isomorphic";
3+
export * from "./reader";
4+
export * from "./types";
5+
export * from "./writer";
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/*
2+
* IMPORTANT:
3+
*
4+
* This file is suffixed with `.isomorphic` because the code in this file is
5+
* meant to run not just in a nodejs environment but also in a browser. For this
6+
* reason there are some restrictions for which nodejs imports are allowed in
7+
* this module. See `.eslintrc.json` for more details.
8+
*/
9+
import { cloneDeep, get, has, set, unset } from "lodash";
10+
11+
import { AnyObj } from "@/lib/helpers/object.isomorphic";
12+
import { ObjKeyOrArrayIdx, ObjPath } from "@/lib/helpers/object.isomorphic";
13+
import { FILEPATH_MARKER } from "@/lib/marshal/shared/const.isomorphic";
14+
import { ExtractionSettings, WithAnnotation } from "@/lib/marshal/shared/types";
15+
16+
import { prepareResourceJson } from "../shared/helpers.isomorphic";
17+
import { ReusableStepData } from "./types";
18+
19+
export const REUSABLE_STEP_JSON = "reusable-step.json";
20+
21+
export type ReusableStepDirBundle = {
22+
[relpath: string]: string;
23+
};
24+
25+
/*
26+
* Traverse a given reusable step data and compile extraction settings of every
27+
* extractable field into a sorted map.
28+
*
29+
* NOTE: Currently we do NOT support content extraction at nested levels for
30+
* reusable steps.
31+
*/
32+
type CompiledExtractionSettings = Map<ObjKeyOrArrayIdx[], ExtractionSettings>;
33+
34+
const compileExtractionSettings = (
35+
reusableStep: ReusableStepData<WithAnnotation>,
36+
): CompiledExtractionSettings => {
37+
const extractableFields = get(
38+
reusableStep,
39+
["__annotation", "extractable_fields"],
40+
{},
41+
);
42+
const map: CompiledExtractionSettings = new Map();
43+
44+
for (const key of Object.keys(reusableStep)) {
45+
// If the field we are on is extractable, then add its extraction
46+
// settings to the map with the current object path.
47+
if (key in extractableFields) {
48+
map.set([key], extractableFields[key]);
49+
}
50+
}
51+
52+
return map;
53+
};
54+
55+
/*
56+
* For a given reusable step payload, this function builds a "reusable step
57+
* directory bundle". This is an object which contains all the relative paths and
58+
* its file content. It includes the extractable fields, which are extracted out
59+
* and added to the bundle as separate files.
60+
*/
61+
export const buildReusableStepDirBundle = (
62+
remoteReusableStep: ReusableStepData<WithAnnotation>,
63+
localReusableStep: AnyObj = {},
64+
): ReusableStepDirBundle => {
65+
const bundle: ReusableStepDirBundle = {};
66+
const mutRemoteReusableStep = cloneDeep(remoteReusableStep);
67+
// A map of extraction settings of every field in the reusable step
68+
const compiledExtractionSettings = compileExtractionSettings(
69+
mutRemoteReusableStep,
70+
);
71+
72+
// Iterate through each extractable field, determine whether we need to
73+
// extract the field content, and if so, perform the extraction.
74+
for (const [objPathParts, extractionSettings] of compiledExtractionSettings) {
75+
// If this reusable step doesn't have this field path, then we don't extract.
76+
if (!has(mutRemoteReusableStep, objPathParts)) continue;
77+
78+
// If the field at this path is extracted in the local reusable step, then
79+
// always extract; otherwise extract based on the field settings default.
80+
const objPathStr = ObjPath.stringify(objPathParts);
81+
82+
const extractedFilePath = get(
83+
localReusableStep,
84+
`${objPathStr}${FILEPATH_MARKER}`,
85+
);
86+
87+
const { default: extractByDefault, file_ext: fileExt } = extractionSettings;
88+
89+
if (!extractedFilePath && !extractByDefault) continue;
90+
91+
// By this point, we have a field where we need to extract its content.
92+
const data = get(mutRemoteReusableStep, objPathParts);
93+
const fileName = objPathParts.pop();
94+
95+
// If we have an extracted file path from the local reusable step, we use that.
96+
// In the other case we use the default path.
97+
const relpath =
98+
typeof extractedFilePath === "string"
99+
? extractedFilePath
100+
: `${fileName}.${fileExt}`;
101+
102+
// Perform the extraction by adding the content and its file path to the
103+
// bundle for writing to the file system later. Then replace the field
104+
// content with the extracted file path and mark the field as extracted
105+
// with @ suffix.
106+
const content =
107+
typeof data === "string" ? data : JSON.stringify(data, null, 2);
108+
109+
set(bundle, [relpath], content);
110+
set(mutRemoteReusableStep, `${objPathStr}${FILEPATH_MARKER}`, relpath);
111+
unset(mutRemoteReusableStep, objPathStr);
112+
}
113+
114+
// At this point the bundle contains all extractable files, so we finally add
115+
// the reusable step JSON relative path + the file content.
116+
117+
return set(
118+
bundle,
119+
[REUSABLE_STEP_JSON],
120+
prepareResourceJson(mutRemoteReusableStep),
121+
);
122+
};

0 commit comments

Comments
 (0)