@optima-crm/shortcodes
v0.1.2
Published
WordPress-compatible [shortcode] parser for Node.js with an optional NestJS integration. Node.js equivalent of the PHP library tpoxa/shortcodes
Downloads
443
Maintainers
Readme
@optima-crm/shortcodes
A [shortcode] parser for Node.js, with an optional NestJS integration.
It is the Node.js equivalent of the PHP library tpoxa/shortcodes
(tpoxa\shortcodes\Shortcode), which ports the WordPress shortcode parser. It uses the same syntax
and parsing rules, so templates written for the PHP library render the same way here:
Hello [user attribute=name],
today is [date format="d/m/Y"].
[box]Enclosed [b]content[/b][/box] [[escaped]]- Framework-agnostic core: no dependencies.
- Async handlers: load data from a database or an API while rendering.
- Explicit context: pass the ids of the records being rendered to
parse(). - Per-render cache (
memo): many tags that need the same record load it only once. - NestJS module: every shortcode can be an injectable class with its own dependencies.
- PHP
date()formats, including a ready-made[date]shortcode. - Written in TypeScript, with type definitions included.
Installation
npm install @optima-crm/shortcodesNode.js 18 or newer is required.
The NestJS integration (@optima-crm/shortcodes/nestjs) additionally needs @nestjs/common,
@nestjs/core (version 10 or newer) and reflect-metadata. A NestJS project has these already.
They are optional peer dependencies, so the core works without them.
Quick start
import { ShortcodeEngine } from '@optima-crm/shortcodes';
const shortcodes = new ShortcodeEngine();
shortcodes.register('name', ({ context }) => context.name);
shortcodes.register('upper', ({ content }) => (content ?? '').toUpperCase());
const html = await shortcodes.parse('Hello [name], [upper]welcome[/upper]!', { name: 'John' });
// → "Hello John, WELCOME!"Tags that are not registered are left in the text unchanged.
Syntax
| Form | Example | Handler receives |
|---|---|---|
| Self-closing | [price], [price /] | content: null |
| With attributes | [property attribute="title" lang=es] | attrs: { attribute: 'title', lang: 'es' } |
| Positional attributes | [gallery 5 "large"] | attrs: { '0': '5', '1': 'large' } |
| Enclosing | [box]inner text[/box] | content: 'inner text' |
| Escaped | [[price]] | not called; prints [price] |
Attribute values can be in double quotes, single quotes or unquoted. Attribute names are lower-cased, and tag names are case-sensitive.
Handlers
A handler is a function, sync or async, that receives one object:
| Field | Description |
|---|---|
| tag | The tag name, e.g. property |
| attrs | Attributes as written in the tag, or null if there are none |
| options | The handler's defaults with attrs applied on top |
| content | Inner text of [tag]…[/tag], or null for a self-closing tag |
| context | The object passed to parse() |
| memo(key, factory) | Runs factory once per parse() call for this key and shares the result |
| parse(text) | Parses nested text with the same context and cache |
It returns a string, a number, a boolean or null/undefined (or a promise of one). null,
undefined and false render as an empty string, and true renders as 1.
Defaults and options
shortcodes.register(
'property',
async ({ options, context, memo }) => {
const property = await memo(`property:${context.propertyId}`, () => db.properties.findById(context.propertyId));
return property?.[options.attribute as string];
},
{ defaults: { attribute: 'reference', lang: 'en' } },
);
await shortcodes.parse('[property] / [property attribute=city]', { propertyId: '42' });
// the property is loaded once for both tagsRegistering several handlers
shortcodes.registerAll([
{ tag: 'first', handler: () => '1' },
{ tag: 'second', handler: () => '2', defaults: { size: 'small' } },
]);
shortcodes.has('first'); // true
shortcodes.tags; // ['first', 'second']
shortcodes.unregister('first');Registering a tag that already exists throws DuplicateShortcodeError. Pass { override: true } to
replace it.
Typed context
interface MailContext {
userId: string;
lang?: string;
}
const shortcodes = new ShortcodeEngine<MailContext>();
shortcodes.register('lang', ({ context }) => context.lang ?? 'en'); // context is typedEngine options
new ShortcodeEngine({
onError: 'throw',
errorListener: (error, tag, source) => console.error(tag, error),
strictAttributes: false,
});| Option | Default | Description |
|---|---|---|
| onError | 'throw' | What to do when a handler throws: 'throw' rejects the whole parse(), 'remove' renders the tag as empty text, 'keep' leaves the original tag in place |
| errorListener | – | Called for every handler error, e.g. for logging |
| strictAttributes | false | Reject attributes that are not in the handler's defaults (UnknownShortcodeAttributeError) |
Errors: ShortcodeError (base class), DuplicateShortcodeError, InvalidShortcodeTagError,
ShortcodeHandlerError (wraps the handler's error in cause), UnknownShortcodeAttributeError.
Built-in [date] shortcode
import { dateShortcode } from '@optima-crm/shortcodes';
shortcodes.registerAll([dateShortcode({ timeZone: 'UTC' })]);
await shortcodes.parse('[date] | [date format="l jS F Y, H:i"]');
// → "2024-02-29 | Thursday 29th February 2024, 13:05"format uses PHP date() format characters and defaults to Y-m-d. Without timeZone, the
Node.js process timezone is used.
The formatter is also exported on its own:
import { formatPhpDate } from '@optima-crm/shortcodes';
formatPhpDate('d/m/Y H:i', new Date(), 'UTC');All PHP format characters are supported. The one difference is T (timezone abbreviation): Node.js
only knows abbreviations such as UTC or EST, so for other zones it prints the offset (+02)
where PHP prints e.g. CEST.
NestJS
// app.module.ts
import { Module } from '@nestjs/common';
import { dateShortcode } from '@optima-crm/shortcodes';
import { ShortcodesModule } from '@optima-crm/shortcodes/nestjs';
@Module({
imports: [
ShortcodesModule.forRoot({
handlers: [dateShortcode({ timeZone: 'UTC' })],
onError: 'throw',
}),
],
})
export class AppModule {}Or configure it from other providers with forRootAsync:
ShortcodesModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
handlers: [dateShortcode({ timeZone: config.get('TIMEZONE') })],
}),
});Class-based shortcodes
Decorate an injectable class with @Shortcode() and list it in the providers of any module. The
module finds and registers it on startup, so it can inject models and services:
import { Injectable } from '@nestjs/common';
import { ShortcodeInvocation } from '@optima-crm/shortcodes';
import { Shortcode, ShortcodeHandler } from '@optima-crm/shortcodes/nestjs';
import { UsersService } from './users.service'; // any service or model of your app
@Injectable()
@Shortcode('user', { defaults: { attribute: 'name' } })
export class UserShortcode implements ShortcodeHandler<{ userId: string }> {
constructor(private readonly users: UsersService) {}
async render({ options, context, memo }: ShortcodeInvocation<{ userId: string }>) {
const user = await memo(`user:${context.userId}`, () => this.users.findById(context.userId));
return user?.[options.attribute as string];
}
}One class can handle several tags: @Shortcode(['tag_one', 'tag_two']).
Rendering
ShortcodesService is global by default (turn this off with isGlobal: false):
import { Injectable } from '@nestjs/common';
import { ShortcodesService } from '@optima-crm/shortcodes/nestjs';
@Injectable()
export class MailService {
constructor(private readonly shortcodes: ShortcodesService) {}
render(template: string, userId: string) {
return this.shortcodes.parse(template, { userId });
}
}Handlers must be singletons. A request-scoped handler is rejected at startup; pass per-request data
through the parse() context instead.
Parsing rules
The parser is a port of tpoxa\shortcodes\Shortcode and follows its rules (the WordPress rules)
exactly, including their edge cases, so existing templates render the same. It is tested against
output recorded from the PHP library itself. Worth knowing:
- Unknown tags, and text such as
array[0], are left unchanged. - A tag name needs at least two characters.
[price-old]and[price_x]are not treated as[price].[price/](no space before/) is only recognised when a plain[price]also appears in the same text. Write[price /]or[price].- A newline or non-breaking space directly after the tag name stops the tag from being recognised. Between attributes, both are fine.
- Attribute values are unescaped like PHP
stripcslashes()(\t,\x41, …). - An unquoted value ending in
/loses it: in[link url=https://a.com/],/]closes the tag. Quote such values:[link url="https://a.com/"]. - Tags are rendered one tag name at a time, in the order the names first appear. When a handler's output contains a tag whose name first appears later in the text, that tag is rendered too.
Differences from tpoxa\shortcodes\Shortcode:
- Handlers can be async, and the data they need is passed in the context (not in a global variable).
- Registering the same tag twice throws, instead of silently keeping the last one.
- Unknown attributes are ignored unless
strictAttributesis on. contentisnullfor self-closing tags (PHP passed''), andparse(null)returns''.
Other exports
For advanced use, the low-level functions are exported too: getShortcodeList(text),
getShortcodeRegexp(tag), parseAttributes(text) and stripcslashes(text).
License
UNLICENSED (all rights reserved).
