Skip to content

Commit

Permalink
#90 Added a listCommands function to find and list all commands in a …
Browse files Browse the repository at this point in the history
…document
  • Loading branch information
jjhbw committed Jun 23, 2020
1 parent 0651417 commit 7e77d2f
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 27 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,20 @@ InternalError
TemplateParseError
```

# Inspecting templates
The `listCommands` function lets you list all the commands in a docx template using the same parser as `createReport`.

```typescript
const template_buffer = fs.readFileSync('template.docx');
const commands = await listCommands(template_buffer, ['{', '}']);

// `commands` will contain something like:
[
{ raw: 'INS some_variable', code: 'some_variable', type: 'INS' },
{ raw: 'IMAGE svgImgFile()', code: 'svgImgFile()', type: 'IMAGE' },
]
```

# Performance & security

**Templates can contain arbitrary javascript code. Beware of code injection risks!**
Expand Down
145 changes: 145 additions & 0 deletions src/__tests__/list_commands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import path from 'path';
import fs from 'fs';
import { listCommands } from '../main';

describe('listCommands', () => {
it('handles simple INS', async () => {
const template = await fs.promises.readFile(
path.join(__dirname, 'fixtures', 'noQuerySimpleInserts.docx')
);
expect(await listCommands(template)).toEqual([
{ raw: 'INS a', code: 'a', type: 'INS' },
{ raw: 'ins b', code: 'b', type: 'INS' },
]);
});

it('handles IMAGE', async () => {
const template = await fs.promises.readFile(
path.join(__dirname, 'fixtures', 'imagesSVG.docx')
);
expect(await listCommands(template, '+++')).toEqual([
{ raw: 'IMAGE svgImgFile()', code: 'svgImgFile()', type: 'IMAGE' },
{ raw: 'IMAGE svgImgStr()', code: 'svgImgStr()', type: 'IMAGE' },
]);
});

it('handles inline FOR loops', async () => {
const template = await fs.promises.readFile(
path.join(__dirname, 'fixtures', 'for1inline.docx')
);
expect(await listCommands(template)).toMatchInlineSnapshot(`
Array [
Object {
"code": "company IN companies",
"raw": "FOR company IN companies",
"type": "FOR",
},
Object {
"code": "$company.name",
"raw": "INS $company.name",
"type": "INS",
},
Object {
"code": "company",
"raw": "END-FOR company",
"type": "END-FOR",
},
]
`);
});

it('handles IF clausess', async () => {
const template = await fs.promises.readFile(
path.join(__dirname, 'fixtures', 'if2.docx')
);
expect(await listCommands(template)).toMatchInlineSnapshot(`
Array [
Object {
"code": "4 > 3",
"raw": "IF 4 > 3",
"type": "IF",
},
Object {
"code": "true",
"raw": "IF true",
"type": "IF",
},
Object {
"code": "",
"raw": "END-IF",
"type": "END-IF",
},
Object {
"code": "",
"raw": "END-IF",
"type": "END-IF",
},
Object {
"code": "4 > 3",
"raw": "IF 4 > 3",
"type": "IF",
},
Object {
"code": "false",
"raw": "IF false",
"type": "IF",
},
Object {
"code": "",
"raw": "END-IF",
"type": "END-IF",
},
Object {
"code": "",
"raw": "END-IF",
"type": "END-IF",
},
Object {
"code": "4 < 3",
"raw": "IF 4 < 3",
"type": "IF",
},
Object {
"code": "true",
"raw": "IF true",
"type": "IF",
},
Object {
"code": "",
"raw": "END-IF",
"type": "END-IF",
},
Object {
"code": "",
"raw": "END-IF",
"type": "END-IF",
},
]
`);
});

it('handles custom delimiter', async () => {
const template = await fs.promises.readFile(
path.join(__dirname, 'fixtures', 'for1customDelimiter.docx')
);
expect(await listCommands(template, '***')).toMatchInlineSnapshot(`
Array [
Object {
"code": "company IN companies",
"raw": "FOR company IN companies",
"type": "FOR",
},
Object {
"code": "$company.name",
"raw": "INS $company.name",
"type": "INS",
},
Object {
"code": "company",
"raw": "END-FOR company",
"type": "END-FOR",
},
]
`);
});
});
70 changes: 66 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
} from './zip';
import { parseXml, buildXml } from './xml';
import preprocessTemplate from './preprocessTemplate';
import { extractQuery, produceJsReport } from './processTemplate';
import {
extractQuery,
produceJsReport,
walkTemplate,
getCommand,
splitCommand,
} from './processTemplate';
import {
UserOptions,
Htmls,
Expand All @@ -18,6 +24,8 @@ import {
Links,
Node,
NonTextNode,
CommandSummary,
BuiltInCommand,
} from './types';
import { addChild, newNonTextNode } from './reportUtils';
import JSZip from 'jszip';
Expand Down Expand Up @@ -59,9 +67,6 @@ async function parseTemplate(template: Buffer) {
return { jsTemplate, mainDocument, zip, contentTypes };
}

// ==========================================
// Main
// ==========================================
/**
* Create Report from docx template
*
Expand Down Expand Up @@ -268,6 +273,63 @@ async function createReport(
return output;
}

/**
* Lists all the commands in a docx template.
*
* example:
* ```js
* const template_buffer = fs.readFileSync('template.docx');
* const commands = await listCommands(template_buffer, ['{', '}']);
* // `commands` will contain something like:
* [
* { raw: 'INS some_variable', code: 'some_variable', type: 'INS' },
* { raw: 'IMAGE svgImgFile()', code: 'svgImgFile()', type: 'IMAGE' },
* ]
* ```
*
* @param template the docx template as a Buffer-like object
* @param delimiter the command delimiter (defaults to ['+++', '+++'])
*/
export async function listCommands(
template: Buffer,
delimiter?: string | [string, string]
): Promise<CommandSummary[]> {
const opts: CreateReportOptions = {
cmdDelimiter: getCmdDelimiter(delimiter),

// Otherwise unused but mandatory options
literalXmlDelimiter: DEFAULT_LITERAL_XML_DELIMITER,
processLineBreaks: true,
noSandbox: false,
additionalJsContext: {},
failFast: false,
rejectNullish: false,
};

const { jsTemplate } = await parseTemplate(template);

logger.debug('Preprocessing template...');
const prepped = preprocessTemplate(jsTemplate, opts.cmdDelimiter);

const commands: CommandSummary[] = [];
await walkTemplate(undefined, prepped, opts, async (data, node, ctx) => {
const raw = getCommand(ctx.cmd, ctx.shorthands);
ctx.cmd = ''; // flush the context
const { cmdName, cmdRest: code } = splitCommand(raw);
const type = cmdName as BuiltInCommand;
if (type != null && type !== 'CMD_NODE') {
commands.push({
raw,
type,
code,
});
}
return undefined;
});

return commands;
}

export async function readContentTypes(zip: JSZip): Promise<NonTextNode> {
const contentTypesXml = await zipGetText(zip, CONTENT_TYPES_PATH);
if (contentTypesXml == null)
Expand Down
2 changes: 1 addition & 1 deletion src/preprocessTemplate.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { insertTextSiblingAfter, getNextSibling } from './reportUtils';
import { Node, CreateReportOptions } from './types';
import { Node } from './types';

// In-place
// In case of split commands (or even split delimiters), joins all the pieces
Expand Down
46 changes: 24 additions & 22 deletions src/processTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Links,
Htmls,
Image,
BUILT_IN_COMMANDS,
} from './types';
import {
NullishCommandResultError,
Expand Down Expand Up @@ -93,7 +94,7 @@ export async function extractQuery(
!parent._fTextNode && // Flow, don't complain
parent._tag === 'w:t'
) {
await processText(null, nodeIn, ctx);
await processText(null, nodeIn, ctx, processCmd);
}
if (ctx.query != null) break;
}
Expand All @@ -117,6 +118,15 @@ export async function produceJsReport(
data: ReportData | undefined,
template: Node,
options: CreateReportOptions
): Promise<ReportOutput> {
return walkTemplate(data, template, options, processCmd);
}

export async function walkTemplate(
data: ReportData | undefined,
template: Node,
options: CreateReportOptions,
processor: CommandProcessor
): Promise<ReportOutput> {
const out: Node = cloneNodeWithoutChildren(template);
const ctx = newContext(options);
Expand Down Expand Up @@ -337,7 +347,7 @@ export async function produceJsReport(
!parent._fTextNode &&
parent._tag === 'w:t'
) {
const result = await processText(data, nodeIn, ctx);
const result = await processText(data, nodeIn, ctx, processor);
if (typeof result === 'string') {
// TODO: use a discriminated union here instead of a type assertion to distinguish TextNodes from NonTextNodes.
const newNodeAsTextNode: TextNode = newNode as TextNode;
Expand Down Expand Up @@ -377,10 +387,17 @@ export async function produceJsReport(
};
}

type CommandProcessor = (
data: ReportData | undefined,
node: Node,
ctx: Context
) => Promise<undefined | string | Error>;

const processText = async (
data: ReportData | undefined,
node: TextNode,
ctx: Context
ctx: Context,
onCommand: CommandProcessor
): Promise<string | Error[]> => {
const { cmdDelimiter, failFast } = ctx.options;
const text = node._text;
Expand All @@ -407,7 +424,7 @@ const processText = async (
// and toggle "command mode"
if (idx < segments.length - 1) {
if (ctx.fCmd) {
const cmdResultText = await processCmd(data, node, ctx);
const cmdResultText = await onCommand(data, node, ctx);
if (cmdResultText != null) {
if (typeof cmdResultText === 'string') {
outText += cmdResultText;
Expand All @@ -431,13 +448,13 @@ const processText = async (
// ==========================================
// Command processor
// ==========================================
const processCmd = async (
const processCmd: CommandProcessor = async (
data: ReportData | undefined,
node: Node,
ctx: Context
): Promise<undefined | string | Error> => {
const cmd = getCommand(ctx.cmd, ctx.shorthands);
ctx.cmd = '';
ctx.cmd = ''; // flush the context
logger.debug(`Processing cmd: ${cmd}`);
try {
const { cmdName, cmdRest } = splitCommand(cmd);
Expand Down Expand Up @@ -557,22 +574,7 @@ const processCmd = async (
}
};

const builtInCommands = [
'QUERY',
'CMD_NODE',
'ALIAS',
'FOR',
'END-FOR',
'IF',
'END-IF',
'INS',
'EXEC',
'IMAGE',
'LINK',
'HTML',
] as const;

const builtInRegexes = builtInCommands.map(word => new RegExp(`^${word}\\b`));
const builtInRegexes = BUILT_IN_COMMANDS.map(word => new RegExp(`^${word}\\b`));

const notBuiltIns = (cmd: string) =>
!builtInRegexes.some(r => r.test(cmd.toUpperCase()));
Expand Down
Loading

0 comments on commit 7e77d2f

Please sign in to comment.