Framework-agnostic core for building typography plugins. Provides rule initialisation, string-phase processing, locale resolution, and a plugin factory — with no knowledge of any AST or framework.
Used internally by @nkardaz/remark-typography and intended as the foundation for any custom typography plugin built on @nkardaz/typography-rules.
npm i -D @nkardaz/typography-coreRequires Node.js ≥ 24.0.0
| Package | Type | Details |
|---|---|---|
| Typography Rules | Rules engine | |
| Typography Core | Core | |
| Adapters | ||
| Remark Typography | Remark adapter | |
| Rehype Typography | Rehype adapter | |
| Obsidian Typography | Obsidian adapter | |
| Vanilla Typography | HTML5 adapter | |
| Plugins | ||
| Preview Typography | Obsidian plugin |
@nkardaz/typography-core does three things:
- Initialises rules — registers built-in and custom rule sets via
initRules - Processes strings — applies all string-phase rules to a text value via
applyRules - Creates plugins — provides
createTypographyPluginas a generic factory for building framework-specific plugins with a standardised config contract
Registers rule sets based on the resolved config. Called once per plugin instantiation.
import { initRules } from '@nkardaz/typography-core';
initRules({
initTypographyRules: true,
initMarkupRules: false,
locale: 'en',
plugins: [myCustomRules],
logs: false,
});Behaviour:
- If
initTypographyRulesistrue— callsinitTypographyRules()from@nkardaz/typography-rules - If
initMarkupRulesistrue— callsinitMarkupRules()from@nkardaz/typography-rules - Runs each plugin in
pluginsasplugin()()
Applies all string-phase rules (replace, transform, function→string) to a text value.
Protected regions (URLs, emails, code spans, etc.) are shielded before rules run and restored after.
import { applyRules } from '@nkardaz/typography-core';
const result = applyRules('Hello -- world', 'en', { logs: false });Node-type rules (kind: 'node') are skipped here — they require AST access and are handled
by the framework-specific plugin layer.
Applies node-phase rules (kind === 'node' and function→Node[]) to a flat list of
text nodes belonging to a single parent element, mutating parent.children in-place.
Operates on one level of the tree only — it does not recurse. Traversal and locale
switching across element boundaries is the caller's responsibility (see processElement).
Must be called after applyRules + joinNodes/splitNodes have already handled
string-phase processing on the same text nodes.
import { applyRules, applyNodeRules } from '@nkardaz/typography-core';
import { joinNodes, splitNodes } from '@nkardaz/typography-rules/helpers';
import type { ElementNode, TextNode } from '@nkardaz/typography-rules';
const textNodes = element.children.filter((c): c is TextNode => c.type === 'text');
if (textNodes.length > 0) {
// Phase 1 — string rules
const combined = joinNodes(textNodes);
const transformed = applyRules(combined, locale, { logs: false });
splitNodes(transformed, textNodes);
// Phase 2 — node rules (mutates element.children in-place)
applyNodeRules(textNodes, element, locale, { logs: false });
}| Parameter | Type | Description |
|---|---|---|
textNodes |
TextNode[] |
Direct text-node children of parent, pre-filtered |
parent |
ElementNode |
The element whose children array is mutated on expansion |
locale |
string |
Active locale for rule selection |
config |
Pick<ResolvedCoreConfig, 'logs'> |
Only logs is used — controls warning output |
Rules of kind: 'function' are only expanded here if they return Node[].
If a function rule returns a string, it is silently skipped (already handled by applyRules).
Recursively processes an element and all its descendants, applying both string-phase and node-phase typography rules. Owns traversal, locale inheritance, and coordinates the two-phase pipeline at each level of the tree.
Locale is resolved per element: if the element carries a lang, language, or locale
attribute, that value is used for the element's subtree; otherwise the parent's locale is
inherited. This mirrors the lang attribute semantics of HTML.
import { processElement } from '@nkardaz/typography-core';
import type { ElementNode } from '@nkardaz/typography-rules';
// Process an entire element tree with a base locale
processElement(rootElement, 'en', { logs: false });
// Mixed-locale content is handled automatically via attributes:
// <p lang="ru">Привет — мир</p> → processed with Russian rules
// <p>Hello -- world</p> → processed with inherited locale| Parameter | Type | Description |
|---|---|---|
element |
ElementNode |
Root of the subtree to process; children mutated in-place |
locale |
string |
Inherited locale from the parent scope |
config |
Pick<ResolvedCoreConfig, 'logs'> |
Only logs is used — controls warning output |
Locale attribute priority: lang → language → locale → inherited.
processElementworks withElementNodefrom@nkardaz/typography-rules. For vanilla DOM, write a thin adapter that readselement.getAttribute('lang')and maps DOM nodes toElementNode— the pipeline logic is identical.
Resolves a locale string from a parsed frontmatter object.
Checks keys in order: locale → lang → language.
import { getFrontmatterLocale } from '@nkardaz/typography-core';
const locale = getFrontmatterLocale({ lang: 'ru' }); // → 'ru'Emits a prefixed console.warn if showLogs is true.
import { warning } from '@nkardaz/typography-core';
warning('No rules registered for locale “is”', config.logs);Generic factory for building framework-specific typography plugins.
Handles config merging, default resolution, and initRules — so the plugin
author only provides the handler logic.
import { createTypographyPlugin } from '@nkardaz/typography-core';
export const myPlugin = createTypographyPlugin({
defaultOptions: {
locale: 'de',
},
createHandler: (config) => (tree) => {
// your AST traversal here
// config is fully resolved: ResolvedCoreConfig & TOptions
},
});The returned myPlugin is a standard two-call plugin function:
myPlugin() // default options
myPlugin({ locale: 'fr' }) // override optionsConfig resolution order (last wins):
factory defaults ← createTypographyPlugin defaultOptions ← user options
// Functions
export { initRules } from '@nkardaz/typography-core';
export { applyRules } from '@nkardaz/typography-core';
export { applyNodeRules } from '@nkardaz/typography-core';
export { processElement } from '@nkardaz/typography-core';
export { getFrontmatterLocale } from '@nkardaz/typography-core';
export { warning } from '@nkardaz/typography-core';
export { createTypographyPlugin } from '@nkardaz/typography-core';
// Types
export type { TypographyCoreOptions, ResolvedCoreConfig, PluginFactory } from '@nkardaz/typography-core';import type {
TypographyCoreOptions,
ResolvedCoreConfig,
PluginFactory,
} from '@nkardaz/typography-core';Options accepted by any plugin built with createTypographyPlugin.
export interface TypographyCoreOptions {
initTypographyRules?: boolean;
initMarkupRules?: boolean;
locale?: string;
plugins?: (() => () => void)[];
logs?: boolean;
}| Option | Type | Default | Description |
|---|---|---|---|
initTypographyRules |
boolean |
true |
Register built-in typography rules from @nkardaz/typography-rules |
initMarkupRules |
boolean |
false |
Register built-in markup rules from @nkardaz/typography-rules |
locale |
string |
'en' |
Default locale for rule selection |
plugins |
(() => () => void)[] |
[] |
Custom rule plugins. Each is a factory returning a thunk: () => () => void |
logs |
boolean |
false |
Emit warnings for missing locales and rule errors |
Required<TypographyCoreOptions> — all fields guaranteed present. This is what
createHandler receives.
export interface PluginFactory<TOptions extends TypographyCoreOptions, TTree> {
defaultOptions?: Partial<TOptions>;
createHandler: (config: ResolvedCoreConfig & TOptions) => (tree: TTree) => void;
}Extend TypographyCoreOptions with your own fields and pass a typed factory:
import { createTypographyPlugin, type TypographyCoreOptions } from '@nkardaz/typography-core';
import { myRules } from './rules';
interface MyPluginOptions extends TypographyCoreOptions {
strictMode?: boolean;
}
export const myTypographyPlugin = createTypographyPlugin<MyPluginOptions, MyTree>({
defaultOptions: {
locale: 'fr',
plugins: [myRules],
strictMode: false,
},
createHandler: (config) => (tree) => {
if (config.strictMode) {
// strict processing
}
// traverse tree, call applyRules, etc.
},
});The factory automatically calls initRules with the resolved config before
invoking createHandler. No need to call it manually.
Typography processing is split into two sequential phases. Both must run in order for full rule coverage.
Phase 1 — string rules (applyRules): handles replace, transform, and function→string rules.
Operates on raw text; protected regions (URLs, code, etc.) are shielded automatically.
Phase 2 — node rules (applyNodeRules): handles node and function→Node[] rules.
Expands text nodes into mixed text/element trees (e.g. wrapping H[^2]O into H<sup>2</sup>O).
Must run after phase 1 because node expansion on unsettled text produces incorrect results.
The helpers joinNodes / splitNodes from @nkardaz/typography-rules/helpers bridge sibling text
nodes into a single string before phase 1, so rules can see context across node boundaries.
For most cases processElement handles both phases and full traversal automatically:
import { processElement } from '@nkardaz/typography-core';
processElement(rootElement, 'en', { logs: false });When building a custom plugin that needs finer control, the two phases can be called directly.
This is exactly what processElement does internally at each level:
import { joinNodes, splitNodes } from '@nkardaz/typography-rules/helpers';
import { applyRules, applyNodeRules } from '@nkardaz/typography-core';
import type { ElementNode, TextNode } from '@nkardaz/typography-rules';
function processLevel(element: ElementNode, locale: string, config: ResolvedCoreConfig) {
const lang =
element.attrs?.['lang'] ??
element.attrs?.['language'] ??
element.attrs?.['locale'] ??
locale;
const textNodes = element.children.filter((c): c is TextNode => c.type === 'text');
if (textNodes.length > 0) {
// Phase 1 — join siblings → string rules → split back
const combined = joinNodes(textNodes);
const transformed = applyRules(combined, lang, config);
splitNodes(transformed, textNodes);
// Phase 2 — node-expanding rules
applyNodeRules(textNodes, element, lang, config);
}
// Recurse into non-text children with updated locale
for (const child of element.children) {
if (child.type !== 'text') {
processLevel(child as ElementNode, lang, config);
}
}
}
applyNodeRulesoperates on a single level only — it does not recurse. Traversal and locale propagation are always the caller's responsibility.