Skip to content

Commit ea21d57

Browse files
committed
feat: rerendering of templates
1 parent 4c55b18 commit ea21d57

File tree

10 files changed

+202
-33
lines changed

10 files changed

+202
-33
lines changed

packages/email/docsoc-mail-merge/src/cli.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import { TemplatePreviews } from "./engines/types";
1111
import { getFileNameSchemeInteractively } from "./interactivity/getFileNameSchemeInteractively";
1212
import getRunNameInteractively from "./interactivity/getRunNameInteractively";
1313
import mapCSVFieldsInteractive from "./interactivity/mapCSVFieldsInteractive";
14-
import { getRecordPreviewPrefixForIndividual, getRecordPreviewPrefixForMetadata, writeMetadata } from "./sideCarData";
15-
import { SidecardData } from "./sideCarData/types";
14+
import { getRecordPreviewPrefixForIndividual, writeMetadata } from "./previews/sidecarData";
1615
import { stopIfCriticalFsError } from "./util/files";
1716
import createLogger from "./util/logger";
1817
import { CliOptions, CSVRecord } from "./util/types";
@@ -83,14 +82,15 @@ async function main(opts: CliOptions) {
8382

8483
// 8: Render intermediate results
8584
logger.info("Rendering template previews/intermediates...");
85+
// NOTE: CSVRecord here is the record with the CSV headers mapped to the template fields, rather than with the raw template fields
8686
const previews: [TemplatePreviews, CSVRecord][] = await Promise.all(
8787
records.map(async (csvRecord) => {
8888
const preparedRecord = Object.fromEntries(
8989
Object.entries(csvRecord).map(([key, value]) => {
9090
return [fieldsMapCSVtoTemplate.get(key) ?? key, value];
9191
}),
9292
);
93-
return [await engine.renderPreview(preparedRecord), csvRecord];
93+
return [await engine.renderPreview(preparedRecord), preparedRecord];
9494
}),
9595
);
9696

@@ -106,12 +106,7 @@ async function main(opts: CliOptions) {
106106
const operations = previews.map(async (preview) => {
107107
const fileName = getRecordPreviewPrefixForIndividual(record, fileNamer, opts.templateEngine, preview);
108108
logger.debug(`Writing ${fileName}__${opts.templateEngine}__${preview.name}`);
109-
await stopIfCriticalFsError(
110-
fs.writeFile(
111-
join(previewsRoot, `${fileName}__${opts.templateEngine}__${preview.name}`),
112-
preview.content,
113-
),
114-
);
109+
await stopIfCriticalFsError(fs.writeFile(join(previewsRoot, fileName), preview.content));
115110
});
116111

117112
// Add metadata write operation

packages/email/docsoc-mail-merge/src/engines/nunjucks-md/index.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import nunjucks from "nunjucks";
33

44
import { renderMarkdownToHtml } from "../../markdown/toHtml";
55
import { CSVRecord } from "../../util/types";
6-
import { TemplateEngineOptions } from "../types";
6+
import { TemplateEngineOptions, TemplatePreviews } from "../types";
77
import { TemplateEngine } from "../types";
88
import getTemplateFields from "./getFields";
9-
import { assertIsNunjucksTemplateOptions, NunjucksMarkdownTemplateOptions } from "./types";
9+
import { assertIsNunjucksTemplateOptions, NunjucksMarkdownTemplateOptions, NunjucksSidecarMetadata } from "./types";
1010

1111
/**
1212
* A Nunjucks Markdown template engine
@@ -35,19 +35,31 @@ export default class NunjucksMarkdownEngine extends TemplateEngine {
3535
this.templateOptions = templateOptions;
3636
}
3737

38-
async loadTemplate() {
38+
private async renderMarkdownToHtmlInsideWrapper(markdown: string) {
39+
// Render the MD to HTML
40+
const htmlWrapper = await fs.readFile(this.templateOptions.rootHtmlTemplate, "utf-8");
41+
const htmlWrapperCompiled = nunjucks.compile(htmlWrapper, nunjucks.configure({ autoescape: false }));
42+
const html = renderMarkdownToHtml(markdown);
43+
44+
// Wrap the rendered markdown html in the wrapper
45+
const wrapped = htmlWrapperCompiled.render({ content: html });
46+
47+
return wrapped;
48+
}
49+
50+
override async loadTemplate() {
3951
this.loadedTemplate = await fs.readFile(this.templateOptions.templatePath, "utf-8");
4052
}
4153

42-
extractFields() {
54+
override extractFields() {
4355
if (!this.loadedTemplate) {
4456
throw new Error("Template not loaded");
4557
}
4658

4759
return getTemplateFields(this.loadedTemplate);
4860
}
4961

50-
async renderPreview(record: CSVRecord) {
62+
override async renderPreview(record: CSVRecord) {
5163
if (!this.loadedTemplate) {
5264
throw new Error("Template not loaded");
5365
}
@@ -58,18 +70,13 @@ export default class NunjucksMarkdownEngine extends TemplateEngine {
5870
throwOnUndefined: true,
5971
}),
6072
);
61-
const htmlWrapper = await fs.readFile(this.templateOptions.rootHtmlTemplate, "utf-8");
62-
const htmlWrapperCompiled = nunjucks.compile(htmlWrapper, nunjucks.configure({ autoescape: false }));
6373

6474
// Render the Markdown template with the record, so that we have something to preview
6575
const expanded = templateCompiled.render({
6676
name: record["name"],
6777
});
6878
// Render the MD to HTML
69-
const html = renderMarkdownToHtml(expanded);
70-
71-
// Wrap the rendered markdown html in the wrapper
72-
const wrapped = htmlWrapperCompiled.render({ content: html });
79+
const wrappedHtml = await this.renderMarkdownToHtmlInsideWrapper(expanded);
7380

7481
// Return both
7582
return [
@@ -82,11 +89,44 @@ export default class NunjucksMarkdownEngine extends TemplateEngine {
8289
},
8390
{
8491
name: "Preview-HTML.html",
85-
content: wrapped, // this is what will be sent - do not edit it, re-rendering will overwrite it
92+
content: wrappedHtml, // this is what will be sent - do not edit it, re-rendering will overwrite it
8693
metadata: {
8794
type: "html",
8895
},
8996
},
9097
];
9198
}
99+
100+
/**
101+
* The rerenderer is simple - we just re-render the HTML preview!
102+
*/
103+
override async rerenderPreviews(loadedPreviews: TemplatePreviews): Promise<TemplatePreviews> {
104+
const markdownPreview = loadedPreviews.find(
105+
(preview) => (preview.metadata as NunjucksSidecarMetadata).type === "markdown",
106+
);
107+
if (!markdownPreview) {
108+
throw new Error("No markdown preview found in sidecar data");
109+
}
110+
const htmlPreview = loadedPreviews.find(
111+
(preview) => (preview.metadata as NunjucksSidecarMetadata).type === "html",
112+
);
113+
if (!htmlPreview) {
114+
throw new Error("No HTML preview found in sidecar data");
115+
}
116+
117+
if (!this.loadedTemplate) {
118+
throw new Error("Template not loaded");
119+
}
120+
121+
// Re-render the markdown preview
122+
const html = await this.renderMarkdownToHtmlInsideWrapper(markdownPreview.content);
123+
124+
return [
125+
markdownPreview,
126+
{
127+
...htmlPreview,
128+
content: html,
129+
},
130+
];
131+
}
92132
}

packages/email/docsoc-mail-merge/src/engines/nunjucks-md/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export interface NunjucksMarkdownTemplateOptions {
1111
[key: string]: string;
1212
}
1313

14+
export interface NunjucksSidecarMetadata {
15+
type: "markdown" | "html";
16+
[key: string]: unknown;
17+
}
18+
1419
/**
1520
* Asserts that the given options are valid Nunjucks template options & throws an error if they are not
1621
*/

packages/email/docsoc-mail-merge/src/engines/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ export abstract class TemplateEngine {
5454
* @returns A promise that resolves to an array of {@link TemplatePreviews} objects - check the type for more information
5555
*/
5656
abstract renderPreview(record: CSVRecord): Promise<TemplatePreviews>;
57+
58+
/**
59+
* Given previews generated by {@link TemplateEngine.renderPreview} or this method, re-render a preview.
60+
*
61+
* The idea of this method is that you would generate several previews for a record,
62+
* and then allow one or multiple of them to be edited.
63+
*
64+
* The user then triggers a rerender, and you should update the other previews accordingly.
65+
*
66+
* Example: One preview is a markdown preview, another is a HTML preview.
67+
* The user edits the markdown preview and you then re-render the HTML preview.
68+
*
69+
* **IMPORTANT NOTE**: You _must_ rerender _all_ previews, even if only one was edited. This is a replace-all operations, not a patch.
70+
* @param loadedPreview
71+
* @param associatedRecord
72+
*/
73+
abstract rerenderPreviews(loadedPreviews: TemplatePreviews, associatedRecord: CSVRecord): Promise<TemplatePreviews>;
5774
}
5875

5976
/**

packages/email/docsoc-mail-merge/src/interactivity/mapCSVFieldsInteractive.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import createLogger from "../util/logger";
1212
const logger = createLogger("docsoc.util.mapInteractive");
1313

1414
/**
15-
* Interactive mapping of CSV fields to template fields
15+
* Interactive mapping of CSV fields to template fields.
16+
*
17+
* Prints a prompt for each CSV header, asking the user to select the corresponding template field.
18+
* Finishes with a warning if not all template fields were mapped.
19+
*
20+
* NOTE: It is recommend to use this function in a CLI environment, as it uses inquirer for interactive prompts.
21+
* NOTE: Provide this function with any special fields you need e.g. email, name, etc.
1622
* @param templateFields Set of fields the template wants, extracted using {@link TemplateEngine.extractFields}
1723
* @param csvHeaders Headers from the CSV
1824
* @returns Map of csv headers to template fields
@@ -35,6 +41,8 @@ const mapCSVFieldsInteractive = async (
3541
name: header,
3642
message: `Map CSV field \`${header}\` to template field:`,
3743
choices: Array.from(templateFields),
44+
// Set default to index in templateField that matches the csvHeader
45+
default: Array.from(templateFields).indexOf(header) > 0 ? header : undefined,
3846
})),
3947
);
4048
for (const [csvHeader, templateFieldChosen] of Object.entries(await prompter)) {

packages/email/docsoc-mail-merge/src/sideCarData/index.ts renamed to packages/email/docsoc-mail-merge/src/previews/sidecarData.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import { TemplatePreview, TemplatePreviews } from "../engines/types";
1212
import { stopIfCriticalFsError } from "../util/files";
1313
import createLogger from "../util/logger";
1414
import { CliOptions, CSVRecord } from "../util/types";
15-
import { SidecardData } from "./types";
15+
import { SidecarData } from "./types";
1616

1717
const PARTS_SEPARATOR = "__";
18+
const METADATA_FILE_SUFFIX = "-metadata.json";
1819
const logger = createLogger("docsoc.sidecar");
1920

2021
/**
@@ -54,11 +55,11 @@ export const getRecordPreviewPrefixForIndividual = (
5455
* // => "file_1-metadata.json"
5556
*/
5657
export const getRecordPreviewPrefixForMetadata = (record: CSVRecord, fileNamer: (record: CSVRecord) => string) =>
57-
`${getRecordPreviewPrefix(record, fileNamer)}-metadata.json`;
58+
`${getRecordPreviewPrefix(record, fileNamer)}${METADATA_FILE_SUFFIX}`;
5859

5960
/**
6061
* Write the metadata for a record & its associated previews to a JSON file.
61-
* @param record The record to write metadata for
62+
* @param record The record to write metadata for, mapped to the fields required by the template
6263
* @param templateEngine The engine used to render the previews
6364
* @param templateOptions The options given to the engine
6465
* @param previews The previews rendered for the record
@@ -74,7 +75,8 @@ export async function writeMetadata(
7475
fileNamer: (record: CSVRecord) => string,
7576
previewsRoot: string,
7677
): Promise<void> {
77-
const sidecar: SidecardData = {
78+
const sidecar: SidecarData = {
79+
name: fileNamer(record),
7880
record: record,
7981
engine: templateEngine,
8082
engineOptions: templateOptions,
@@ -89,6 +91,32 @@ export async function writeMetadata(
8991

9092
const metadataFile = getRecordPreviewPrefixForMetadata(record, fileNamer);
9193
logger.debug(`Writing metadata for ${fileNamer(record)} to ${metadataFile}`);
92-
await stopIfCriticalFsError(fs.writeFile(join(previewsRoot, metadataFile), JSON.stringify(sidecar, null, 4)));
94+
await writeSidecarFile(previewsRoot, metadataFile, sidecar);
9395
return Promise.resolve();
9496
}
97+
98+
/**
99+
* Write the sidecar metadata file for a record
100+
*/
101+
export async function writeSidecarFile(previewsRoot: string, metadataFile: string, sidecar: SidecarData) {
102+
await stopIfCriticalFsError(fs.writeFile(join(previewsRoot, metadataFile), JSON.stringify(sidecar, null, 4)));
103+
}
104+
105+
/**
106+
* Load sidecar metadata files from a directory
107+
* @param previewsRoot The root directory to load sidecar metadata from
108+
* @returns An async iterator of sidecar metadata objects
109+
*/
110+
export async function* loadSidecars(
111+
previewsRoot: string,
112+
): AsyncIterableIterator<SidecarData & { $originalfilename: string }> {
113+
logger.info(`Loading sidecar metadata from ${previewsRoot}`);
114+
const files = await fs.readdir(previewsRoot);
115+
const metadataFiles = files.filter((file) => file.endsWith(METADATA_FILE_SUFFIX));
116+
117+
for (const metadataFile of metadataFiles) {
118+
logger.debug(`Loading metadata from ${metadataFile}`);
119+
const metadata = await fs.readFile(join(previewsRoot, metadataFile), "utf-8");
120+
yield { ...(JSON.parse(metadata) as SidecarData), $originalfilename: metadataFile };
121+
}
122+
}

packages/email/docsoc-mail-merge/src/sideCarData/types.ts renamed to packages/email/docsoc-mail-merge/src/previews/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { CSVRecord } from "../util/types";
55
/**
66
* Outputted to JSON files next to rendered template previews, containing metadata about the preview.
77
*/
8-
export interface SidecardData {
8+
export interface SidecarData {
9+
/** Name of the template rendered (used for logging) */
10+
name: string;
911
/** Record associated with the template rendered */
1012
record: CSVRecord;
1113
/** Engine used */
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import fs from "fs/promises";
2+
import { join } from "path";
3+
4+
import packageJSON from "../package.json";
5+
import { ENGINES_MAP } from "./engines";
6+
import { TemplatePreviews } from "./engines/types";
7+
import { loadSidecars, writeSidecarFile } from "./previews/sidecarData";
8+
import { stopIfCriticalFsError } from "./util/files";
9+
import createLogger from "./util/logger";
10+
11+
const logger = createLogger("docsoc");
12+
13+
async function main(directory: string) {
14+
logger.info("DoCSoc Mail Merge - rerender");
15+
logger.info(`v${packageJSON.version}`);
16+
17+
logger.info(`Rerendering previews at ${directory}...`);
18+
19+
// 1: Load all sidecars
20+
const sidecars = loadSidecars(directory);
21+
// 2: Map sidecar files & rerender
22+
for await (const sidecar of sidecars) {
23+
const { name, engine: engineName, engineOptions, files, record } = sidecar;
24+
25+
const EngineClass = ENGINES_MAP[engineName as keyof typeof ENGINES_MAP];
26+
if (!EngineClass) {
27+
logger.error(`Invalid template engine: ${engineName}`);
28+
logger.warn(`Skipping record ${name} as the engine is invalid!`);
29+
continue;
30+
}
31+
32+
// Load in the engine
33+
const engine = new EngineClass(engineOptions);
34+
logger.debug(`Loading engine ${engineName} for ${name}...`);
35+
await engine.loadTemplate();
36+
37+
logger.debug("Remapping sidecar files metadata back to TemplatePreviews...");
38+
logger.debug(JSON.stringify(files));
39+
const previews: TemplatePreviews = await Promise.all(
40+
files.map(async (file) => ({
41+
...file.engineData,
42+
content: await stopIfCriticalFsError(fs.readFile(join(directory, file.filename), "utf-8")),
43+
})),
44+
);
45+
46+
// Rerender previews
47+
logger.info(`Rerendering ${name} using engine ${engineName}...`);
48+
const renderedPreviews = await engine.rerenderPreviews(previews, record);
49+
50+
logger.info(`Writing rerendered previews for ${name}...`);
51+
await Promise.all(
52+
renderedPreviews.map(async (preview, idx) => {
53+
const file = files[idx];
54+
logger.debug(`Writing rerendered preview ${file.filename}...`);
55+
await stopIfCriticalFsError(fs.writeFile(join(directory, file.filename), preview.content));
56+
logger.debug("Overwriting sidecar metadata with new metadata...");
57+
sidecar.files[idx].engineData = { ...preview, content: undefined };
58+
}),
59+
);
60+
61+
logger.info(`Updating sidecar metadata for ${name} at ${sidecar.$originalfilename}...`);
62+
await writeSidecarFile(directory, sidecar.$originalfilename, sidecar);
63+
64+
logger.info(`Finished rerendering ${name}`);
65+
}
66+
}
67+
68+
if (process.argv.length < 3) {
69+
logger.error("Please provide the directory to rerender previews in");
70+
process.exit(1);
71+
}
72+
73+
main(process.argv[2]);

packages/email/docsoc-mail-merge/src/util/files.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export async function stopIfCriticalFsError<T>(promise: Promise<T>) {
1717
} catch (e) {
1818
if (e instanceof Error && isErrnoException(e)) {
1919
if (e?.code === "ENOENT") {
20-
logger.error("File not found: ", e.message);
20+
logger.error("File not found: ", e);
2121
} else if (e?.code === "EACCES") {
2222
logger.error("Permission denied: ", e.message);
2323
} else {
@@ -28,4 +28,4 @@ export async function stopIfCriticalFsError<T>(promise: Promise<T>) {
2828
}
2929
process.exit(1);
3030
}
31-
}
31+
}

0 commit comments

Comments
 (0)