Last active
November 23, 2023 21:23
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: ':' | |
}) | |
}) | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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