diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts
index 97c5400b4d..ff9b3ea848 100644
--- a/src/patcher/from-docx.ts
+++ b/src/patcher/from-docx.ts
@@ -17,7 +17,7 @@ import { appendRelationship, getNextRelationshipIndex } from "./relationship-man
import { appendContentType } from "./content-types-manager";
// eslint-disable-next-line functional/prefer-readonly-type
-type InputDataType = Buffer | string | number[] | Uint8Array | ArrayBuffer | Blob | NodeJS.ReadableStream;
+export type InputDataType = Buffer | string | number[] | Uint8Array | ArrayBuffer | Blob | NodeJS.ReadableStream;
export const PatchType = {
DOCUMENT: "file",
diff --git a/src/patcher/index.ts b/src/patcher/index.ts
index 466cb3eda7..786e57341f 100644
--- a/src/patcher/index.ts
+++ b/src/patcher/index.ts
@@ -1 +1,2 @@
export * from "./from-docx";
+export * from "./patch-detector";
diff --git a/src/patcher/patch-detector.spec.ts b/src/patcher/patch-detector.spec.ts
new file mode 100644
index 0000000000..961502c11a
--- /dev/null
+++ b/src/patcher/patch-detector.spec.ts
@@ -0,0 +1,225 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import JSZip from "jszip";
+import { patchDetector } from "./patch-detector";
+
+const MOCK_XML = `
+
+
+
+
+
+
+
+
+ Hello World
+
+
+
+
+
+ Hello {{name}},
+
+
+ how are you?
+
+
+
+
+
+ {{paragraph_replace}}
+
+
+
+
+
+ {{table}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{table_heading_1}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Item: {{item_1}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{image_test}}
+
+
+
+
+
+ Thank you
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+describe("patch-detector", () => {
+ describe("patchDetector", () => {
+ describe("document.xml and [Content_Types].xml", () => {
+ beforeEach(() => {
+ vi.spyOn(JSZip, "loadAsync").mockReturnValue(
+ new Promise((resolve) => {
+ const zip = new JSZip();
+
+ zip.file("word/document.xml", MOCK_XML);
+ zip.file("[Content_Types].xml", ``);
+ resolve(zip);
+ }),
+ );
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should patch the document", async () => {
+ const output = await patchDetector({
+ data: Buffer.from(""),
+ });
+ expect(output).toMatchObject(["name", "paragraph_replace", "table", "image_test", "table_heading_1", "item_1"]);
+ });
+ });
+ });
+});
diff --git a/src/patcher/patch-detector.ts b/src/patcher/patch-detector.ts
new file mode 100644
index 0000000000..7ebcede050
--- /dev/null
+++ b/src/patcher/patch-detector.ts
@@ -0,0 +1,30 @@
+import JSZip from "jszip";
+import { toJson } from "./util";
+import { traverse } from "./traverser";
+import { InputDataType } from "./from-docx";
+
+type PatchDetectorOptions = {
+ readonly data: InputDataType;
+};
+
+/** Detects which patches are needed/present in a template */
+export const patchDetector = async ({ data }: PatchDetectorOptions): Promise => {
+ const zipContent = await JSZip.loadAsync(data);
+ const patches = new Set();
+
+ for (const [key, value] of Object.entries(zipContent.files)) {
+ if (!key.endsWith(".xml") && !key.endsWith(".rels")) {
+ continue;
+ }
+ if (key.startsWith("word/") && !key.endsWith(".xml.rels")) {
+ const json = toJson(await value.async("text"));
+ traverse(json).forEach((p) => findPatchKeys(p.text).forEach((patch) => patches.add(patch)));
+ }
+ }
+ return Array.from(patches);
+};
+
+const findPatchKeys = (text: string): readonly string[] => {
+ const pattern = /(?<=\{\{).+?(?=\}\})/gs;
+ return text.match(pattern) ?? [];
+};
diff --git a/src/patcher/traverser.ts b/src/patcher/traverser.ts
index 52112e10b9..b05279696d 100644
--- a/src/patcher/traverser.ts
+++ b/src/patcher/traverser.ts
@@ -15,7 +15,7 @@ const elementsToWrapper = (wrapper: ElementWrapper): readonly ElementWrapper[] =
parent: wrapper,
})) ?? [];
-export const findLocationOfText = (node: Element, text: string): readonly IRenderedParagraphNode[] => {
+export const traverse = (node: Element): readonly IRenderedParagraphNode[] => {
let renderedParagraphs: readonly IRenderedParagraphNode[] = [];
// eslint-disable-next-line functional/prefer-readonly-type
@@ -41,5 +41,8 @@ export const findLocationOfText = (node: Element, text: string): readonly IRende
}
}
- return renderedParagraphs.filter((p) => p.text.includes(text));
+ return renderedParagraphs;
};
+
+export const findLocationOfText = (node: Element, text: string): readonly IRenderedParagraphNode[] =>
+ traverse(node).filter((p) => p.text.includes(text));