@nkardaz/typography-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yalla Nkardaz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,368 @@
1
+ # @nkardaz/typography-core
2
+
3
+ Framework-agnostic core for building typography plugins.
4
+ Provides rule initialisation, string-phase processing, locale resolution,
5
+ and a plugin factory — with no knowledge of any AST or framework.
6
+
7
+ Used internally by [@nkardaz/remark-typography](https://github.com/DemerNkardaz/remark-typography)
8
+ and intended as the foundation for any custom typography plugin built on
9
+ [@nkardaz/typography-rules](https://github.com/DemerNkardaz/typography-rules).
10
+
11
+ ---
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm i -D @nkardaz/typography-core
17
+ ```
18
+
19
+ > **Requires Node.js ≥ 24.0.0**
20
+
21
+ ---
22
+
23
+ ## Overview
24
+
25
+ `@nkardaz/typography-core` does three things:
26
+
27
+ - **Initialises rules** — registers built-in and custom rule sets via `initRules`
28
+ - **Processes strings** — applies all string-phase rules to a text value via `applyRules`
29
+ - **Creates plugins** — provides `createTypographyPlugin` as a generic factory for building framework-specific plugins with a standardised config contract
30
+
31
+ ---
32
+
33
+ ## API
34
+
35
+ ### `initRules(config: ResolvedCoreConfig): void`
36
+
37
+ Registers rule sets based on the resolved config. Called once per plugin instantiation.
38
+
39
+ ```typescript
40
+ import { initRules } from '@nkardaz/typography-core';
41
+
42
+ initRules({
43
+ initTypographyRules: true,
44
+ initMarkupRules: false,
45
+ locale: 'en',
46
+ plugins: [myCustomRules],
47
+ logs: false,
48
+ });
49
+ ```
50
+
51
+ Behaviour:
52
+ - If `initTypographyRules` is `true` — calls `initTypographyRules()` from `@nkardaz/typography-rules`
53
+ - If `initMarkupRules` is `true` — calls `initMarkupRules()` from `@nkardaz/typography-rules`
54
+ - Runs each plugin in `plugins` as `plugin()()`
55
+
56
+ ---
57
+
58
+ ### `applyRules(text, locale, config): string`
59
+
60
+ Applies all string-phase rules (`replace`, `transform`, `function→string`) to a text value.
61
+ Protected regions (URLs, emails, code spans, etc.) are shielded before rules run and restored after.
62
+
63
+ ```typescript
64
+ import { applyRules } from '@nkardaz/typography-core';
65
+
66
+ const result = applyRules('Hello -- world', 'en', { logs: false });
67
+ ```
68
+
69
+ Node-type rules (`kind: 'node'`) are skipped here — they require AST access and are handled
70
+ by the framework-specific plugin layer.
71
+
72
+ ---
73
+
74
+ ### `applyNodeRules(textNodes, parent, locale, config): void`
75
+
76
+ Applies node-phase rules (`kind === 'node'` and `function`→`Node[]`) to a flat list of
77
+ text nodes belonging to a single parent element, mutating `parent.children` in-place.
78
+
79
+ Operates on one level of the tree only — it does not recurse. Traversal and locale
80
+ switching across element boundaries is the caller's responsibility (see `processElement`).
81
+
82
+ Must be called **after** `applyRules` + `joinNodes`/`splitNodes` have already handled
83
+ string-phase processing on the same text nodes.
84
+
85
+ ```typescript
86
+ import { applyRules, applyNodeRules } from '@nkardaz/typography-core';
87
+ import { joinNodes, splitNodes } from '@nkardaz/typography-rules/helpers';
88
+ import type { ElementNode, TextNode } from '@nkardaz/typography-rules';
89
+
90
+ const textNodes = element.children.filter((c): c is TextNode => c.type === 'text');
91
+
92
+ if (textNodes.length > 0) {
93
+ // Phase 1 — string rules
94
+ const combined = joinNodes(textNodes);
95
+ const transformed = applyRules(combined, locale, { logs: false });
96
+ splitNodes(transformed, textNodes);
97
+
98
+ // Phase 2 — node rules (mutates element.children in-place)
99
+ applyNodeRules(textNodes, element, locale, { logs: false });
100
+ }
101
+ ```
102
+
103
+ | Parameter | Type | Description |
104
+ | ----------- | ---------------------------------- | -------------------------------------------------------- |
105
+ | `textNodes` | `TextNode[]` | Direct text-node children of `parent`, pre-filtered |
106
+ | `parent` | `ElementNode` | The element whose `children` array is mutated on expansion |
107
+ | `locale` | `string` | Active locale for rule selection |
108
+ | `config` | `Pick<ResolvedCoreConfig, 'logs'>` | Only `logs` is used — controls warning output |
109
+
110
+ Rules of `kind: 'function'` are only expanded here if they return `Node[]`.
111
+ If a function rule returns a `string`, it is silently skipped (already handled by `applyRules`).
112
+
113
+ ---
114
+
115
+ ### `processElement(element, locale, config): void`
116
+
117
+ Recursively processes an element and all its descendants, applying both string-phase and
118
+ node-phase typography rules. Owns traversal, locale inheritance, and coordinates the
119
+ two-phase pipeline at each level of the tree.
120
+
121
+ Locale is resolved per element: if the element carries a `lang`, `language`, or `locale`
122
+ attribute, that value is used for the element's subtree; otherwise the parent's locale is
123
+ inherited. This mirrors the `lang` attribute semantics of HTML.
124
+
125
+ ```typescript
126
+ import { processElement } from '@nkardaz/typography-core';
127
+ import type { ElementNode } from '@nkardaz/typography-rules';
128
+
129
+ // Process an entire element tree with a base locale
130
+ processElement(rootElement, 'en', { logs: false });
131
+
132
+ // Mixed-locale content is handled automatically via attributes:
133
+ // <p lang="ru">Привет — мир</p> → processed with Russian rules
134
+ // <p>Hello -- world</p> → processed with inherited locale
135
+ ```
136
+
137
+ | Parameter | Type | Description |
138
+ | --------- | ---------------------------------- | ------------------------------------------------------------ |
139
+ | `element` | `ElementNode` | Root of the subtree to process; `children` mutated in-place |
140
+ | `locale` | `string` | Inherited locale from the parent scope |
141
+ | `config` | `Pick<ResolvedCoreConfig, 'logs'>` | Only `logs` is used — controls warning output |
142
+
143
+ Locale attribute priority: `lang` → `language` → `locale` → inherited.
144
+
145
+ > `processElement` works with `ElementNode` from `@nkardaz/typography-rules`. For vanilla DOM,
146
+ > write a thin adapter that reads `element.getAttribute('lang')` and maps DOM nodes to
147
+ > `ElementNode` — the pipeline logic is identical.
148
+
149
+ ---
150
+
151
+ ### `getFrontmatterLocale(data): string | undefined`
152
+
153
+ Resolves a locale string from a parsed frontmatter object.
154
+ Checks keys in order: `locale` → `lang` → `language`.
155
+
156
+ ```typescript
157
+ import { getFrontmatterLocale } from '@nkardaz/typography-core';
158
+
159
+ const locale = getFrontmatterLocale({ lang: 'ru' }); // → 'ru'
160
+ ```
161
+
162
+ ---
163
+
164
+ ### `warning(message, showLogs): void`
165
+
166
+ Emits a prefixed `console.warn` if `showLogs` is `true`.
167
+
168
+ ```typescript
169
+ import { warning } from '@nkardaz/typography-core';
170
+
171
+ warning('No rules registered for locale “is”', config.logs);
172
+ ```
173
+
174
+ ---
175
+
176
+ ### `createTypographyPlugin(factory): (options?) => handler`
177
+
178
+ Generic factory for building framework-specific typography plugins.
179
+ Handles config merging, default resolution, and `initRules` — so the plugin
180
+ author only provides the handler logic.
181
+
182
+ ```typescript
183
+ import { createTypographyPlugin } from '@nkardaz/typography-core';
184
+
185
+ export const myPlugin = createTypographyPlugin({
186
+ defaultOptions: {
187
+ locale: 'de',
188
+ },
189
+ createHandler: (config) => (tree) => {
190
+ // your AST traversal here
191
+ // config is fully resolved: ResolvedCoreConfig & TOptions
192
+ },
193
+ });
194
+ ```
195
+
196
+ The returned `myPlugin` is a standard two-call plugin function:
197
+
198
+ ```typescript
199
+ myPlugin() // default options
200
+ myPlugin({ locale: 'fr' }) // override options
201
+ ```
202
+
203
+ Config resolution order (last wins):
204
+
205
+ ```
206
+ factory defaults ← createTypographyPlugin defaultOptions ← user options
207
+ ```
208
+
209
+ ---
210
+
211
+ ## Exports
212
+
213
+ ```typescript
214
+ // Functions
215
+ export { initRules } from '@nkardaz/typography-core';
216
+ export { applyRules } from '@nkardaz/typography-core';
217
+ export { applyNodeRules } from '@nkardaz/typography-core';
218
+ export { processElement } from '@nkardaz/typography-core';
219
+ export { getFrontmatterLocale } from '@nkardaz/typography-core';
220
+ export { warning } from '@nkardaz/typography-core';
221
+ export { createTypographyPlugin } from '@nkardaz/typography-core';
222
+
223
+ // Types
224
+ export type { TypographyCoreOptions, ResolvedCoreConfig, PluginFactory } from '@nkardaz/typography-core';
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Types
230
+
231
+ ```typescript
232
+ import type {
233
+ TypographyCoreOptions,
234
+ ResolvedCoreConfig,
235
+ PluginFactory,
236
+ } from '@nkardaz/typography-core';
237
+ ```
238
+
239
+ ### `TypographyCoreOptions`
240
+
241
+ Options accepted by any plugin built with `createTypographyPlugin`.
242
+
243
+ ```typescript
244
+ export interface TypographyCoreOptions {
245
+ initTypographyRules?: boolean;
246
+ initMarkupRules?: boolean;
247
+ locale?: string;
248
+ plugins?: (() => () => void)[];
249
+ logs?: boolean;
250
+ }
251
+ ```
252
+
253
+ | Option | Type | Default | Description |
254
+ | --------------------- | ---------------------- | ------- | --------------------------------------------------------------------------- |
255
+ | `initTypographyRules` | `boolean` | `true` | Register built-in typography rules from `@nkardaz/typography-rules` |
256
+ | `initMarkupRules` | `boolean` | `false` | Register built-in markup rules from `@nkardaz/typography-rules` |
257
+ | `locale` | `string` | `'en'` | Default locale for rule selection |
258
+ | `plugins` | `(() => () => void)[]` | `[]` | Custom rule plugins. Each is a factory returning a thunk: `() => () => void` |
259
+ | `logs` | `boolean` | `false` | Emit warnings for missing locales and rule errors |
260
+
261
+ ### `ResolvedCoreConfig`
262
+
263
+ `Required<TypographyCoreOptions>` — all fields guaranteed present. This is what
264
+ `createHandler` receives.
265
+
266
+ ### `PluginFactory<TOptions, TTree>`
267
+
268
+ ```typescript
269
+ export interface PluginFactory<TOptions extends TypographyCoreOptions, TTree> {
270
+ defaultOptions?: Partial<TOptions>;
271
+ createHandler: (config: ResolvedCoreConfig & TOptions) => (tree: TTree) => void;
272
+ }
273
+ ```
274
+
275
+ ---
276
+
277
+ ## Building a Custom Plugin
278
+
279
+ Extend `TypographyCoreOptions` with your own fields and pass a typed factory:
280
+
281
+ ```typescript
282
+ import { createTypographyPlugin, type TypographyCoreOptions } from '@nkardaz/typography-core';
283
+ import { myRules } from './rules';
284
+
285
+ interface MyPluginOptions extends TypographyCoreOptions {
286
+ strictMode?: boolean;
287
+ }
288
+
289
+ export const myTypographyPlugin = createTypographyPlugin<MyPluginOptions, MyTree>({
290
+ defaultOptions: {
291
+ locale: 'fr',
292
+ plugins: [myRules],
293
+ strictMode: false,
294
+ },
295
+ createHandler: (config) => (tree) => {
296
+ if (config.strictMode) {
297
+ // strict processing
298
+ }
299
+ // traverse tree, call applyRules, etc.
300
+ },
301
+ });
302
+ ```
303
+
304
+ The factory automatically calls `initRules` with the resolved config before
305
+ invoking `createHandler`. No need to call it manually.
306
+
307
+ ---
308
+
309
+ ## Two-Phase Processing Pattern
310
+
311
+ Typography processing is split into two sequential phases. Both must run in order for full rule coverage.
312
+
313
+ **Phase 1 — string rules** (`applyRules`): handles `replace`, `transform`, and `function`→`string` rules.
314
+ Operates on raw text; protected regions (URLs, code, etc.) are shielded automatically.
315
+
316
+ **Phase 2 — node rules** (`applyNodeRules`): handles `node` and `function`→`Node[]` rules.
317
+ Expands text nodes into mixed text/element trees (e.g. wrapping `H[^2]O` into `H<sup>2</sup>O`).
318
+ Must run after phase 1 because node expansion on unsettled text produces incorrect results.
319
+
320
+ The helpers `joinNodes` / `splitNodes` from `@nkardaz/typography-rules/helpers` bridge sibling text
321
+ nodes into a single string before phase 1, so rules can see context across node boundaries.
322
+
323
+ For most cases `processElement` handles both phases and full traversal automatically:
324
+
325
+ ```typescript
326
+ import { processElement } from '@nkardaz/typography-core';
327
+
328
+ processElement(rootElement, 'en', { logs: false });
329
+ ```
330
+
331
+ When building a custom plugin that needs finer control, the two phases can be called directly.
332
+ This is exactly what `processElement` does internally at each level:
333
+
334
+ ```typescript
335
+ import { joinNodes, splitNodes } from '@nkardaz/typography-rules/helpers';
336
+ import { applyRules, applyNodeRules } from '@nkardaz/typography-core';
337
+ import type { ElementNode, TextNode } from '@nkardaz/typography-rules';
338
+
339
+ function processLevel(element: ElementNode, locale: string, config: ResolvedCoreConfig) {
340
+ const lang =
341
+ element.attrs?.['lang'] ??
342
+ element.attrs?.['language'] ??
343
+ element.attrs?.['locale'] ??
344
+ locale;
345
+
346
+ const textNodes = element.children.filter((c): c is TextNode => c.type === 'text');
347
+
348
+ if (textNodes.length > 0) {
349
+ // Phase 1 — join siblings → string rules → split back
350
+ const combined = joinNodes(textNodes);
351
+ const transformed = applyRules(combined, lang, config);
352
+ splitNodes(transformed, textNodes);
353
+
354
+ // Phase 2 — node-expanding rules
355
+ applyNodeRules(textNodes, element, lang, config);
356
+ }
357
+
358
+ // Recurse into non-text children with updated locale
359
+ for (const child of element.children) {
360
+ if (child.type !== 'text') {
361
+ processLevel(child as ElementNode, lang, config);
362
+ }
363
+ }
364
+ }
365
+ ```
366
+
367
+ > `applyNodeRules` operates on a single level only — it does not recurse.
368
+ > Traversal and locale propagation are always the caller's responsibility.
@@ -0,0 +1,7 @@
1
+ import type { ResolvedCoreConfig, TypographyCoreOptions } from './types';
2
+ export interface PluginFactory<TOptions extends TypographyCoreOptions, TTree> {
3
+ defaultOptions?: Partial<TOptions>;
4
+ createHandler: (config: ResolvedCoreConfig & TOptions) => (tree: TTree) => void;
5
+ }
6
+ export declare function createTypographyPlugin<TOptions extends TypographyCoreOptions, TTree>(factory: PluginFactory<TOptions, TTree>): (options?: Partial<TOptions>) => (tree: TTree) => void;
7
+ //# sourceMappingURL=factory.d.ts.map
package/dist/index.cjs ADDED
@@ -0,0 +1,185 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ applyNodeRules: () => applyNodeRules,
24
+ applyRules: () => applyRules,
25
+ createTypographyPlugin: () => createTypographyPlugin,
26
+ getFrontmatterLocale: () => getFrontmatterLocale,
27
+ initRules: () => initRules,
28
+ processElement: () => processElement,
29
+ warning: () => warning
30
+ });
31
+ module.exports = __toCommonJS(index_exports);
32
+ var import_typography_rules2 = require("@nkardaz/typography-rules");
33
+ var import_helpers = require("@nkardaz/typography-rules/helpers");
34
+
35
+ // src/factory.ts
36
+ var import_typography_rules = require("@nkardaz/typography-rules");
37
+ function createTypographyPlugin(factory) {
38
+ return function plugin(options = {}) {
39
+ const resolved = {
40
+ initTypographyRules: true,
41
+ initMarkupRules: false,
42
+ logs: false,
43
+ locale: "en",
44
+ plugins: [],
45
+ ...factory.defaultOptions,
46
+ ...options
47
+ };
48
+ resolved.locale = import_typography_rules.ALIAS.resolve(resolved.locale) ?? resolved.locale;
49
+ initRules(resolved);
50
+ return factory.createHandler(resolved);
51
+ };
52
+ }
53
+
54
+ // src/index.ts
55
+ function initRules(config) {
56
+ if (config.initTypographyRules) {
57
+ (0, import_typography_rules2.initTypographyRules)();
58
+ }
59
+ if (config.initMarkupRules) {
60
+ (0, import_typography_rules2.initMarkupRules)();
61
+ }
62
+ config.plugins?.forEach((plugin) => plugin()());
63
+ }
64
+ function warning(message, showLogs) {
65
+ if (showLogs) {
66
+ console.warn(`[@nkardaz/typography] ${message}`);
67
+ }
68
+ }
69
+ function getFrontmatterLocale(data) {
70
+ if (!data) return void 0;
71
+ return (typeof data["locale"] === "string" ? data["locale"] : void 0) ?? (typeof data["lang"] === "string" ? data["lang"] : void 0) ?? (typeof data["language"] === "string" ? data["language"] : void 0);
72
+ }
73
+ function applyRules(text, locale, config) {
74
+ const key = import_typography_rules2.ALIAS.resolve(locale) ?? locale;
75
+ const rules = (0, import_typography_rules2.getWeightedRules)(key);
76
+ if (rules.length === 0) return text;
77
+ const [initialProtectedValue, protectedMatches] = (0, import_helpers.protect)(text, key);
78
+ let value = initialProtectedValue;
79
+ for (const item of rules) {
80
+ if (!item?.kind) {
81
+ if (config.logs) console.warn("[@nkardaz/typography] Skipping invalid rule:", item);
82
+ continue;
83
+ }
84
+ if (item.label && (0, import_typography_rules2.isRuleDisabled)(item.label)) continue;
85
+ if (item.kind === "node") continue;
86
+ try {
87
+ switch (item.kind) {
88
+ case "function": {
89
+ const funcItem = item;
90
+ const result = funcItem.rule(value, ...funcItem.args ?? []);
91
+ if (typeof result === "string") value = result;
92
+ break;
93
+ }
94
+ case "transform": {
95
+ const transformItem = item;
96
+ value = value.replace(transformItem.rule, (match, ...groups) => {
97
+ const regexArray = [match, ...groups];
98
+ return transformItem.transform(regexArray);
99
+ });
100
+ break;
101
+ }
102
+ case "replace": {
103
+ const replaceItem = item;
104
+ value = value.replace(replaceItem.rule, replaceItem.replacement);
105
+ break;
106
+ }
107
+ }
108
+ } catch (err) {
109
+ if (config.logs)
110
+ console.warn("[@nkardaz/typography] Rule threw an error, skipping:", item, err);
111
+ }
112
+ }
113
+ return (0, import_helpers.unprotect)(value, protectedMatches);
114
+ }
115
+ function applyNodeRules(textNodes, parent, locale, config) {
116
+ const key = import_typography_rules2.ALIAS.resolve(locale) ?? locale;
117
+ const rules = (0, import_typography_rules2.getWeightedRules)(key).filter(
118
+ (r) => r.kind === "node" || r.kind === "function"
119
+ );
120
+ if (rules.length === 0) return;
121
+ for (const textNode of textNodes) {
122
+ let current = [textNode];
123
+ for (const rule of rules) {
124
+ if (rule.label && (0, import_typography_rules2.isRuleDisabled)(rule.label)) continue;
125
+ const next = [];
126
+ for (const node of current) {
127
+ if (node.type !== "text") {
128
+ next.push(node);
129
+ continue;
130
+ }
131
+ const textValue = node.value;
132
+ let nodeList;
133
+ try {
134
+ if (rule.kind === "node") {
135
+ const nodeRule = rule;
136
+ nodeList = (0, import_typography_rules2.htmlNode)(textValue, {
137
+ expression: nodeRule.rule,
138
+ nodes: nodeRule.nodes
139
+ });
140
+ } else {
141
+ const funcRule = rule;
142
+ const result = funcRule.rule(textValue, ...funcRule.args ?? []);
143
+ if (typeof result === "string" || !Array.isArray(result)) {
144
+ next.push(node);
145
+ continue;
146
+ }
147
+ nodeList = result;
148
+ }
149
+ } catch (err) {
150
+ if (config.logs)
151
+ console.warn("[@nkardaz/typography] Node rule threw an error, skipping:", rule, err);
152
+ next.push(node);
153
+ continue;
154
+ }
155
+ if (nodeList.length === 1 && nodeList[0].type === "text" && nodeList[0].value === textValue) {
156
+ next.push(node);
157
+ continue;
158
+ }
159
+ for (const n of nodeList) next.push(n);
160
+ }
161
+ current = next;
162
+ }
163
+ if (current.length === 1 && current[0] === textNode) continue;
164
+ const index = parent.children.indexOf(textNode);
165
+ if (index !== -1) {
166
+ parent.children.splice(index, 1, ...current);
167
+ }
168
+ }
169
+ }
170
+ function processElement(element, locale, config) {
171
+ const lang = element.attrs?.["lang"] ?? element.attrs?.["language"] ?? element.attrs?.["locale"] ?? locale;
172
+ const textNodes = element.children.filter((c) => c.type === "text");
173
+ if (textNodes.length > 0) {
174
+ const combined = (0, import_helpers.joinNodes)(textNodes);
175
+ const transformed = applyRules(combined, lang, config);
176
+ (0, import_helpers.splitNodes)(transformed, textNodes);
177
+ applyNodeRules(textNodes, element, lang, config);
178
+ }
179
+ for (const child of element.children) {
180
+ if (child.type !== "text") {
181
+ processElement(child, lang, config);
182
+ }
183
+ }
184
+ }
185
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1,53 @@
1
+ import { type ElementNode, type TextNode } from '@nkardaz/typography-rules';
2
+ import type { ResolvedCoreConfig } from './types';
3
+ export * from './factory';
4
+ export type * from './types';
5
+ export declare function initRules(config: ResolvedCoreConfig): void;
6
+ export declare function warning(message: string, showLogs: boolean): void;
7
+ /**
8
+ * Resolve locale from a parsed frontmatter data object.
9
+ * Checks keys in order: `locale` → `lang` → `language`.
10
+ */
11
+ export declare function getFrontmatterLocale(data: Record<string, unknown> | null): string | undefined;
12
+ /**
13
+ * Apply all string-phase rules (replace / transform / function→string) to `text`.
14
+ * Protected regions (URLs, emails, code spans, …) are shielded before rules run.
15
+ *
16
+ * This is the only processing function that is truly framework-agnostic:
17
+ * it operates purely on strings and has no knowledge of any AST.
18
+ */
19
+ export declare function applyRules(text: string, locale: string, config: Pick<ResolvedCoreConfig, 'logs'>): string;
20
+ /**
21
+ * Apply node-phase rules (kind === 'node' | 'function'→Node[]) to a flat list
22
+ * of text nodes belonging to a single parent element, mutating the parent's
23
+ * children array in-place.
24
+ *
25
+ * This function operates on one level of the tree only — it does not recurse.
26
+ * Traversal and locale switching across element boundaries is the caller's
27
+ * responsibility (see `processElement`).
28
+ *
29
+ * Must be called *after* `applyRules` + `joinNodes`/`splitNodes` have already
30
+ * handled string-phase processing on the same text nodes.
31
+ *
32
+ * @param textNodes - Direct text-node children of `parent`, pre-filtered by the caller
33
+ * @param parent - The element whose `children` array will be mutated on expansion
34
+ * @param locale - Active locale for rule selection
35
+ * @param config - Core config; only `logs` is used
36
+ */
37
+ export declare function applyNodeRules(textNodes: TextNode[], parent: ElementNode, locale: string, config: Pick<ResolvedCoreConfig, 'logs'>): void;
38
+ /**
39
+ * Recursively processes an element and all its descendants, applying both
40
+ * string-phase and node-phase typography rules.
41
+ *
42
+ * Mirrors the role of `processNode` in the remark plugin: it owns the locale
43
+ * stack, handles `lang`/`language`/`locale` attribute switching on child
44
+ * elements, and coordinates the two-phase pipeline for each level:
45
+ * 1. `joinNodes` → `applyRules` → `splitNodes` (string phase)
46
+ * 2. `applyNodeRules` (node phase)
47
+ *
48
+ * @param element - The element to process; its `children` array is mutated in-place
49
+ * @param locale - Inherited locale from the parent scope
50
+ * @param config - Core config; only `logs` is used
51
+ */
52
+ export declare function processElement(element: ElementNode, locale: string, config: Pick<ResolvedCoreConfig, 'logs'>): void;
53
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,171 @@
1
+ // src/index.ts
2
+ import {
3
+ getWeightedRules,
4
+ initTypographyRules,
5
+ initMarkupRules,
6
+ isRuleDisabled,
7
+ htmlNode,
8
+ ALIAS as ALIAS2
9
+ } from "@nkardaz/typography-rules";
10
+ import { joinNodes, protect, splitNodes, unprotect } from "@nkardaz/typography-rules/helpers";
11
+
12
+ // src/factory.ts
13
+ import { ALIAS } from "@nkardaz/typography-rules";
14
+ function createTypographyPlugin(factory) {
15
+ return function plugin(options = {}) {
16
+ const resolved = {
17
+ initTypographyRules: true,
18
+ initMarkupRules: false,
19
+ logs: false,
20
+ locale: "en",
21
+ plugins: [],
22
+ ...factory.defaultOptions,
23
+ ...options
24
+ };
25
+ resolved.locale = ALIAS.resolve(resolved.locale) ?? resolved.locale;
26
+ initRules(resolved);
27
+ return factory.createHandler(resolved);
28
+ };
29
+ }
30
+
31
+ // src/index.ts
32
+ function initRules(config) {
33
+ if (config.initTypographyRules) {
34
+ initTypographyRules();
35
+ }
36
+ if (config.initMarkupRules) {
37
+ initMarkupRules();
38
+ }
39
+ config.plugins?.forEach((plugin) => plugin()());
40
+ }
41
+ function warning(message, showLogs) {
42
+ if (showLogs) {
43
+ console.warn(`[@nkardaz/typography] ${message}`);
44
+ }
45
+ }
46
+ function getFrontmatterLocale(data) {
47
+ if (!data) return void 0;
48
+ return (typeof data["locale"] === "string" ? data["locale"] : void 0) ?? (typeof data["lang"] === "string" ? data["lang"] : void 0) ?? (typeof data["language"] === "string" ? data["language"] : void 0);
49
+ }
50
+ function applyRules(text, locale, config) {
51
+ const key = ALIAS2.resolve(locale) ?? locale;
52
+ const rules = getWeightedRules(key);
53
+ if (rules.length === 0) return text;
54
+ const [initialProtectedValue, protectedMatches] = protect(text, key);
55
+ let value = initialProtectedValue;
56
+ for (const item of rules) {
57
+ if (!item?.kind) {
58
+ if (config.logs) console.warn("[@nkardaz/typography] Skipping invalid rule:", item);
59
+ continue;
60
+ }
61
+ if (item.label && isRuleDisabled(item.label)) continue;
62
+ if (item.kind === "node") continue;
63
+ try {
64
+ switch (item.kind) {
65
+ case "function": {
66
+ const funcItem = item;
67
+ const result = funcItem.rule(value, ...funcItem.args ?? []);
68
+ if (typeof result === "string") value = result;
69
+ break;
70
+ }
71
+ case "transform": {
72
+ const transformItem = item;
73
+ value = value.replace(transformItem.rule, (match, ...groups) => {
74
+ const regexArray = [match, ...groups];
75
+ return transformItem.transform(regexArray);
76
+ });
77
+ break;
78
+ }
79
+ case "replace": {
80
+ const replaceItem = item;
81
+ value = value.replace(replaceItem.rule, replaceItem.replacement);
82
+ break;
83
+ }
84
+ }
85
+ } catch (err) {
86
+ if (config.logs)
87
+ console.warn("[@nkardaz/typography] Rule threw an error, skipping:", item, err);
88
+ }
89
+ }
90
+ return unprotect(value, protectedMatches);
91
+ }
92
+ function applyNodeRules(textNodes, parent, locale, config) {
93
+ const key = ALIAS2.resolve(locale) ?? locale;
94
+ const rules = getWeightedRules(key).filter(
95
+ (r) => r.kind === "node" || r.kind === "function"
96
+ );
97
+ if (rules.length === 0) return;
98
+ for (const textNode of textNodes) {
99
+ let current = [textNode];
100
+ for (const rule of rules) {
101
+ if (rule.label && isRuleDisabled(rule.label)) continue;
102
+ const next = [];
103
+ for (const node of current) {
104
+ if (node.type !== "text") {
105
+ next.push(node);
106
+ continue;
107
+ }
108
+ const textValue = node.value;
109
+ let nodeList;
110
+ try {
111
+ if (rule.kind === "node") {
112
+ const nodeRule = rule;
113
+ nodeList = htmlNode(textValue, {
114
+ expression: nodeRule.rule,
115
+ nodes: nodeRule.nodes
116
+ });
117
+ } else {
118
+ const funcRule = rule;
119
+ const result = funcRule.rule(textValue, ...funcRule.args ?? []);
120
+ if (typeof result === "string" || !Array.isArray(result)) {
121
+ next.push(node);
122
+ continue;
123
+ }
124
+ nodeList = result;
125
+ }
126
+ } catch (err) {
127
+ if (config.logs)
128
+ console.warn("[@nkardaz/typography] Node rule threw an error, skipping:", rule, err);
129
+ next.push(node);
130
+ continue;
131
+ }
132
+ if (nodeList.length === 1 && nodeList[0].type === "text" && nodeList[0].value === textValue) {
133
+ next.push(node);
134
+ continue;
135
+ }
136
+ for (const n of nodeList) next.push(n);
137
+ }
138
+ current = next;
139
+ }
140
+ if (current.length === 1 && current[0] === textNode) continue;
141
+ const index = parent.children.indexOf(textNode);
142
+ if (index !== -1) {
143
+ parent.children.splice(index, 1, ...current);
144
+ }
145
+ }
146
+ }
147
+ function processElement(element, locale, config) {
148
+ const lang = element.attrs?.["lang"] ?? element.attrs?.["language"] ?? element.attrs?.["locale"] ?? locale;
149
+ const textNodes = element.children.filter((c) => c.type === "text");
150
+ if (textNodes.length > 0) {
151
+ const combined = joinNodes(textNodes);
152
+ const transformed = applyRules(combined, lang, config);
153
+ splitNodes(transformed, textNodes);
154
+ applyNodeRules(textNodes, element, lang, config);
155
+ }
156
+ for (const child of element.children) {
157
+ if (child.type !== "text") {
158
+ processElement(child, lang, config);
159
+ }
160
+ }
161
+ }
162
+ export {
163
+ applyNodeRules,
164
+ applyRules,
165
+ createTypographyPlugin,
166
+ getFrontmatterLocale,
167
+ initRules,
168
+ processElement,
169
+ warning
170
+ };
171
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1,9 @@
1
+ export interface TypographyCoreOptions {
2
+ initTypographyRules?: boolean;
3
+ initMarkupRules?: boolean;
4
+ locale?: string;
5
+ plugins?: (() => () => void)[];
6
+ logs?: boolean;
7
+ }
8
+ export type ResolvedCoreConfig = Required<TypographyCoreOptions>;
9
+ //# sourceMappingURL=types.d.ts.map
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@nkardaz/typography-core",
3
+ "version": "1.0.0",
4
+ "description": "Your package description here",
5
+ "license": "MIT",
6
+ "author": "Yalla Nkardaz",
7
+ "keywords": [
8
+ "typography",
9
+ "typographic",
10
+ "text-formatting",
11
+ "typesetting",
12
+ "english",
13
+ "russian",
14
+ "text"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/DemerNkardaz/typography-core.git"
19
+ },
20
+ "homepage": "https://github.com/DemerNkardaz/typography-core#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/DemerNkardaz/typography-core/issues"
23
+ },
24
+ "type": "module",
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.mjs",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.mjs",
32
+ "require": "./dist/index.cjs"
33
+ }
34
+ },
35
+ "bundleSizeLimit": 20480,
36
+ "sideEffects": false,
37
+ "engines": {
38
+ "node": ">=24.0.0"
39
+ },
40
+ "scripts": {
41
+ "build:js": "node esbuild.config.mjs",
42
+ "build:types": "tsc --project tsconfig.build.json --emitDeclarationOnly",
43
+ "build": "npm run build:js && npm run build:types",
44
+ "dev": "esbuild --watch",
45
+ "lint": "eslint src --ext .ts,.tsx",
46
+ "lint:fix": "eslint src --ext .ts,.tsx --fix",
47
+ "format": "prettier --write \"src/**/*.{ts,tsx,json,md}\"",
48
+ "type-check": "tsc --noEmit",
49
+ "test": "vitest --run",
50
+ "test:coverage": "vitest --run --coverage",
51
+ "prepublishOnly": "npm run build && npm run lint && npm run type-check",
52
+ "knip": "knip"
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/js": "^10.0.1",
56
+ "@types/node": "^25.9.1",
57
+ "@vitest/coverage-v8": "^4.1.8",
58
+ "esbuild": "^0.28.0",
59
+ "eslint": "^10.4.1",
60
+ "eslint-config-prettier": "^10.1.8",
61
+ "knip": "^6.15.0",
62
+ "prettier": "^3.0.0",
63
+ "typescript": "^6.0.3",
64
+ "typescript-eslint": "^8.60.1",
65
+ "vite-tsconfig-paths": "^6.1.1",
66
+ "vitest": "^4.1.8"
67
+ },
68
+ "dependencies": {
69
+ "@nkardaz/typography-rules": "1.0.0"
70
+ }
71
+ }