Skip to content

Instantly share code, notes, and snippets.

@ezze
Last active November 23, 2023 21:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ezze/720c80af9f49a3acc5c5e03900063606 to your computer and use it in GitHub Desktop.
Save ezze/720c80af9f49a3acc5c5e03900063606 to your computer and use it in GitHub Desktop.
Custom scope data resolver for easy-template-x with extensions and angular expressions support
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import { TemplateData } from 'easy-template-x';
import { TemplateResolverError } from './resolver';
import { TemplateResolvedValue } from './types';
type DateTimeOptions = {
format?: string;
};
export function dateTime(
name: string,
value: TemplateResolvedValue,
data: TemplateData,
options?: DateTimeOptions
): string {
if (!value || (typeof value !== 'number' && typeof value !== 'string')) {
return '';
}
const { format = 'DD-MM-YYYY HH:mm:ss' } = options || {};
try {
return dayjs(value)
.locale('ru')
.format(format);
} catch (e) {
throw new TemplateResolverError(`Unable to format date/time "${name}": ${value}`);
}
}
import { AngularFilter } from 'easy-template-x-angular-expressions';
import { integerFilter } from './integer';
import { kebabCaseFilter } from './kebabcase';
import { lowerCaseFilter } from './lowercase';
import { numericFilter } from './numeric';
import { stringifyFilter } from './stringify';
import { upperCaseFilter } from './uppercase';
export const expressionFilters: Record<string, AngularFilter> = {
integer: integerFilter,
numeric: numericFilter,
stringify: stringifyFilter,
uppercase: upperCaseFilter,
lowercase: lowerCaseFilter,
kebabcase: kebabCaseFilter
};
import { TemplateExtension } from 'features/document/generator/template/types';
import { currency } from './currency';
import { dateTime } from './date-time';
import { integer } from './integer';
import { person } from './person';
export const resolverExtensions: Record<string, TemplateExtension> = {
person,
dateTime,
currency,
integer
};
import {
ImagePlugin,
LinkPlugin,
RawXmlPlugin,
TemplateData,
TemplateHandler,
TemplatePlugin,
TextPlugin
} from 'easy-template-x';
import { createResolver as createAngularExpressionsResolver } from 'easy-template-x-angular-expressions';
import { LoopPlugin } from './loop-plugin'; // see https://gist.github.com/ezze/c6f51a16a35f333137bc0411bf0c91ea
import { expressionFilters } from './expression-filters';
import { resolverExtensions } from './extensions';
import { createResolver } from './resolver';
export const DEFAULT_FILE_NAME = 'Готовый документ.pdf';
const plugins: Array<TemplatePlugin> = [
new LoopPlugin(),
new RawXmlPlugin(),
new ImagePlugin(),
new LinkPlugin(),
new TextPlugin()
];
export function createDocumentTemplateHandler(): TemplateHandler {
return new TemplateHandler({
plugins,
scopeDataResolver: createResolver({
extensions: resolverExtensions,
fallbackResolver: createAngularExpressionsResolver({
angularFilters: expressionFilters,
requiredPrefix: ':'
})
})
});
}
export const digitRegExp = /^\d+$/;
export const integerRegExp = /^[+-]?\d+$/;
export const numericRegExp = /^[+-]?\d+([.,]\d+(e[+-]\d+)?)?$/i;
// https://stackoverflow.com/a/3143231/506695
export const iso8601RegExp =
/^(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))$/;
export function createRegExpFromString(string: string, flags?: string): RegExp {
const pattern = string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(pattern, flags);
}
import angularExpressions from 'angular-expressions';
import {
ScopeData,
ScopeDataArgs,
ScopeDataResolver,
TemplateContent,
TemplateData,
isNumber,
PluginContent
} from 'easy-template-x';
import get from 'lodash/get';
import { digitRegExp } from './regexp';
import { expressionFilters } from './expression-filters';
import {
TemplateParsedExpression,
TemplateResolvedValue,
TemplateExtensionOptions,
TemplateResolverOptions,
TemplateExpressionParser,
TemplateResolvedData
} from './types';
export class TemplateResolverError extends Error {}
const defaultPrefix = '~';
const defaultSeparator = ':';
const defaultFallbackResolver = ScopeData.defaultResolver.bind(ScopeData);
const quotes = '\'"`”';
const openQuotes = `«“„‘`;
const closeQuotes = '»”’';
const allQuotes = `${quotes}${openQuotes}${closeQuotes}`;
const namePattern = '([a-zA-Z0-9]+)';
const valuePattern = `((true|false)|(-?[0-9]+(\\.[0-9]+)?)|[${quotes}${openQuotes}]([^${allQuotes}]*)[${quotes}${closeQuotes}])`;
const optionPattern = `${namePattern}=${valuePattern}`;
const globalOptionsRegExp = new RegExp(`^${optionPattern}( ${optionPattern})*$`); // regexp to test options in general
const globalOptionsParseRegExp = new RegExp(optionPattern, 'g'); // regexp to split options into array
const optionRegExp = new RegExp(optionPattern); // regexp to parse each option separately
const pathPattern = '[a-zA-Z0-9]+(\\.[a-zA-Z0-9]+)*';
const expressionPattern = `([^ ${allQuotes}]+)|[${quotes}${openQuotes}](.+)[${quotes}${closeQuotes}]`;
const expressionExtensionNames = ['expression', 'e', 'exp', 'expr'];
Object.assign(angularExpressions.filters, expressionFilters);
function escape(regExp: string) {
return regExp.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
}
function createTemplateResolverError(input: string, extension?: string, expression?: string) {
let error: string;
if (extension && expression) {
error = `Options for extension "${extension}" and expression "${expression}" are invalid: ${input}`;
} else {
error = `Extension options are invalid: ${input}`;
}
return new TemplateResolverError(error);
}
export function parseTemplateExtensionOptions(
input: string,
extension?: string,
expression?: string
): TemplateExtensionOptions {
if (!globalOptionsRegExp.test(input)) {
throw createTemplateResolverError(input, extension, expression);
}
const optionItems = input.match(globalOptionsParseRegExp);
// It must be impossible, but we have to check whether result is not null
if (!optionItems) {
throw createTemplateResolverError(input, extension, expression);
}
const options: TemplateExtensionOptions = {};
optionItems.forEach((optionItem) => {
const match = optionItem.match(optionRegExp);
if (!match) {
throw new TemplateResolverError(`Unable to parse option: ${optionItem}`);
}
const name = match[1];
const booleanValue = match[3];
const numberValue = match[4];
const stringValue = match[6];
if (booleanValue) {
options[name] = booleanValue === 'true';
} else if (numberValue) {
options[name] = Number(numberValue);
} else if (stringValue || stringValue === '') {
options[name] = stringValue;
} else {
throw new TemplateResolverError(`Unable to parse option: ${optionItem}`);
}
});
return options;
}
function parseBasicTemplateExpression(
expression: string,
prefix: string,
separator: string
): TemplateParsedExpression | undefined {
const regExp = new RegExp(`^${escape(prefix)}([a-zA-Z0-9]+)${escape(separator)}(${pathPattern})( (.+))?$`);
const match = expression.match(regExp);
if (!match) {
return undefined;
}
const extensionName = match[1];
if (expressionExtensionNames.includes(extensionName)) {
return undefined;
}
const input = match[2];
const optionsString = match[5];
return {
extensionName,
input,
compile: false,
options: optionsString ? parseTemplateExtensionOptions(optionsString, extensionName, input) : {}
};
}
function parseCompiledTemplateExpression(
expression: string,
prefix: string,
separator: string
): TemplateParsedExpression | undefined {
const regExp = new RegExp(
`^${escape(prefix)}(${expressionExtensionNames.join('|')}|([a-zA-Z0-9]+)@(${expressionExtensionNames.join(
'|'
)}))${escape(separator)}(${expressionPattern})( (.+))?$`
);
const match = expression.match(regExp);
if (!match) {
return undefined;
}
const extensionName = match[2] || expressionExtensionNames[0];
const input = match[5] || match[6];
const optionsString = match[8];
return {
extensionName,
input,
compile: true,
options: optionsString ? parseTemplateExtensionOptions(optionsString, extensionName, input) : {}
};
}
const templateExpressionParsers: Array<TemplateExpressionParser> = [
parseBasicTemplateExpression,
parseCompiledTemplateExpression
];
export function parseTemplateExpression(
expression: string,
prefix = defaultPrefix,
separator = defaultSeparator
): TemplateParsedExpression | undefined {
let parsedExpression: TemplateParsedExpression | undefined;
let i = 0;
while (parsedExpression === undefined && i < templateExpressionParsers.length) {
const parse = templateExpressionParsers[i];
parsedExpression = parse(expression, prefix, separator);
i += 1;
}
return parsedExpression;
}
export function resolveTemplateValue(
name: string,
data: TemplateData,
itemPath: Array<string> = []
): TemplateResolvedValue {
const path = [...itemPath, ...name.split('.')];
let value: TemplateContent | TemplateData | TemplateData[] | undefined = data;
for (let i = 0; i < path.length; i += 1) {
const item = digitRegExp.test(path[i]) ? Number(path[i]) : path[i];
let found = false;
if (value && !PluginContent.isPluginContent(value)) {
if (typeof item === 'string' && !Array.isArray(value) && typeof value === 'object') {
value = value[item];
found = true;
}
if (typeof item === 'number' && Array.isArray(value)) {
value = value[item];
found = true;
}
}
if (!found) {
return undefined;
}
}
return value;
}
export function createResolver(options?: TemplateResolverOptions): ScopeDataResolver {
const {
extensions = {},
prefix = defaultPrefix,
separator = defaultSeparator,
fallbackResolver = defaultFallbackResolver
} = options || {};
return function resolve(args: ScopeDataArgs): TemplateResolvedData {
const { data, path, strPath } = args;
if (!path.length) {
return fallbackResolver(args);
}
// Fallback on number paths generated by the loop plugin.
const lastPart = path[path.length - 1];
if (isNumber(lastPart)) {
return fallbackResolver(args);
}
const expression = (lastPart?.name || '').trim();
if (expression.indexOf(prefix) !== 0) {
return fallbackResolver(args);
}
const parsedExpression = parseTemplateExpression(expression, prefix, separator);
if (!parsedExpression) {
return fallbackResolver(args);
}
const { extensionName, input, compile, options } = parsedExpression;
const extension = extensions[extensionName];
if (!compile && !extension) {
throw new TemplateResolverError(`Extension "${extensionName}" is not supported`);
}
const normalizedStrPath = strPath.slice(0, strPath.length - 1);
let resolvedValue: TemplateResolvedValue;
if (compile) {
const evaluate = angularExpressions.compile(input.replace(/[’‘]/g, "'").replace(/[“”]/g, '"'));
const scope: object = { ...data };
let currentScope: unknown = scope;
for (let i = 0; i < normalizedStrPath.length; i += 1) {
const key = normalizedStrPath[i];
currentScope = get(currentScope, key);
if (!currentScope || typeof currentScope !== 'object') {
break;
}
Object.assign(scope, currentScope);
}
resolvedValue = evaluate(scope) as TemplateResolvedData;
if (!extension) {
return resolvedValue;
}
} else {
resolvedValue = resolveTemplateValue(input, data, normalizedStrPath);
}
return extension(input, resolvedValue, data, options);
};
}
import { ScopeDataResolver, TemplateContent, TemplateData, ImageContent } from 'easy-template-x';
export type TemplateImageResolver = (image: string | Blob) => Promise<ImageContent>;
export class TemplateImageResolverError extends Error {}
export type TemplateResolvedData = TemplateContent | Array<TemplateData>;
export type TemplateResolvedValue = TemplateContent | TemplateData | TemplateData[] | undefined;
export type TemplateExtensionOptions = Record<string, string | number | boolean>;
export type TemplateExtension = (
name: string,
value: TemplateResolvedValue,
data: TemplateData,
options?: TemplateExtensionOptions
) => TemplateResolvedData;
export type TemplateResolverOptions = {
extensions?: Record<string, TemplateExtension>;
prefix?: string;
separator?: string;
fallbackResolver?: ScopeDataResolver;
};
export type TemplateParsedExpression = {
extensionName: string;
input: string;
compile: boolean;
options: TemplateExtensionOptions;
};
export type TemplateExpressionParser = (
expression: string,
prefix: string,
separator: string
) => TemplateParsedExpression | undefined;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment