@sidvishnoi/imd 0.0.1
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/README.md +108 -0
- package/dist/core.d.ts +30 -0
- package/dist/core.js +92 -0
- package/dist/dom.d.ts +3 -0
- package/dist/dom.js +14 -0
- package/dist/html.d.ts +3 -0
- package/dist/html.js +17 -0
- package/dist/preact.d.ts +4 -0
- package/dist/preact.js +13 -0
- package/dist/react.d.ts +4 -0
- package/dist/react.js +9 -0
- package/package.json +86 -0
- package/src/core.ts +147 -0
- package/src/dom.ts +15 -0
- package/src/html.ts +21 -0
- package/src/preact.ts +17 -0
- package/src/react.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @sidvishnoi/imd
|
|
2
|
+
|
|
3
|
+
A tiny inline-markdown renderer for small strings, with support for React, Preact, DOM, and HTML strings.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
i18n strings often need a bit of inline formatting (a bold word, a link), but pulling in a full markdown parser for that is overkill, and hand-splitting strings around `<a>` tags in every component is repetitive and easy to get wrong across languages. `imd` gives translators a tiny, predictable syntax to write in, and gives you one `md()` call per rendering target that turns their string into real elements, with full control over how each piece (a link, a button, a custom tag) actually renders.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Install the package (`npm i -S @sidvishnoi/imd`), then import the backend for whatever you're rendering to. Each module exports the same shape: `md(text, renderers?)`.
|
|
12
|
+
|
|
13
|
+
### React
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { md } from '@sidvishnoi/imd/react';
|
|
17
|
+
|
|
18
|
+
const TEXT = 'Please review our [terms of service](/terms) before continuing.';
|
|
19
|
+
|
|
20
|
+
{md(TEXT, { link: (children, url) => (<a href={url} className="link">{children}</a>) })}
|
|
21
|
+
// Renders: Please review our <a href="/terms" class="link">terms of service</a> before continuing.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Preact
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { md } from '@sidvishnoi/imd/preact';
|
|
28
|
+
|
|
29
|
+
const TEXT = 'Your changes are *saved* automatically.';
|
|
30
|
+
|
|
31
|
+
{md(TEXT, { bold: (children) => <strong class="font-bold">{children}</strong> })}
|
|
32
|
+
// Renders: Your changes are <strong class="font-bold">saved</strong> automatically.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### DOM
|
|
36
|
+
|
|
37
|
+
Returns `Node[]`; insert with `container.replaceChildren(...)`.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { md } from '@sidvishnoi/imd/dom';
|
|
41
|
+
|
|
42
|
+
const TEXT = "This can't be undone. <button>Delete account</button>";
|
|
43
|
+
|
|
44
|
+
el.replaceChildren(
|
|
45
|
+
...md(TEXT, {
|
|
46
|
+
button: (children) => {
|
|
47
|
+
const btn = document.createElement('button');
|
|
48
|
+
btn.className = 'btn-danger';
|
|
49
|
+
btn.append(...children);
|
|
50
|
+
return btn;
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
// el's markup is now:
|
|
55
|
+
// This can't be undone. <button class="btn-danger">Delete account</button>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### HTML string
|
|
59
|
+
|
|
60
|
+
Returns a `string`; children arrive already joined, so you can interpolate them directly.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { md } from '@sidvishnoi/imd/html';
|
|
64
|
+
|
|
65
|
+
const TEXT = 'Enter your <mark>API key</mark> to continue.';
|
|
66
|
+
|
|
67
|
+
const markup = md(TEXT, {
|
|
68
|
+
html: ({ tag, children }) => `<${tag} class="highlight">${children}</${tag}>`,
|
|
69
|
+
});
|
|
70
|
+
// markup:
|
|
71
|
+
// 'Enter your <mark class="highlight">API key</mark> to continue.'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Every renderer above is optional. Anything you don't override falls back to a plain element (`<a href="…">`, `<strong>`, `<em>`) with no attributes added, so `md(text)` with no second argument always works. Renderers just let you attach classes, handlers, or a11y attributes at the call site.
|
|
75
|
+
|
|
76
|
+
Bold, italics and hyperlinks have a simple to use syntax:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
*bold* -> bold(children) => <strong>{children}</strong>
|
|
80
|
+
_italic_ -> italic(children) => <em>{children}</em>
|
|
81
|
+
[text](url) -> link(children, url) => <a href="{url}">{children}</a>
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For every raw-HTML string, use the `html` renderer to attach custom handlers:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
<tag attr="…">…</tag> -> html({ tag, children, attrs }) => <tag {...attrs}>{children}</tag>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
For more examples (custom tags, `button`, the `html` catch-all, edge cases in parsing), see [`./tests`](./tests); each source module has a matching test file.
|
|
92
|
+
|
|
93
|
+
## How it works
|
|
94
|
+
|
|
95
|
+
`md()` does two things under the hood.
|
|
96
|
+
|
|
97
|
+
First it parses: the string is scanned left to right for the four patterns above (link, bold, italic, custom tag). At each position the earliest match wins, and if two rules could match at the same spot, link beats bold beats italic beats a custom tag. This ordering is what gives the syntax its known quirks (see Caveats).
|
|
98
|
+
|
|
99
|
+
Then it renders: the resulting tree is walked node by node. For each element, it tries your renderer for that tag (`link`/`bold`/`italic`/`button`) first, then your generic `html` catch-all if you passed one, then falls back to a plain default element. The first one that doesn't return `undefined` wins.
|
|
100
|
+
|
|
101
|
+
All of this parsing and rendering logic lives once in a framework-agnostic core. `react`, `preact`, `dom`, and `html` are each just a few lines telling it how to construct an element, a text node, and how to combine a list of children for that target.
|
|
102
|
+
|
|
103
|
+
## Caveats
|
|
104
|
+
|
|
105
|
+
- There are no lists, headings, code blocks, or block-level anything, so this isn't CommonMark. It's just enough syntax for a translator to write `click *here*` or `see [our terms](url)` inside a single string.
|
|
106
|
+
- Custom tags can't nest and can't self-close, and attribute values must be quoted; there's no escaping syntax either. This is a fixed grammar, not an extensible one.
|
|
107
|
+
- Because matching is regex and position based rather than a real parser, delimiters are naive: `2 * 3 *` reads as bold, and `a_b_c` reads as italic. That's fine for short, authored UI copy, but reword or restructure the source string if you hit it.
|
|
108
|
+
- Treat these strings as authored content, not user input. This is meant for text your team or translators write, not text submitted by end users.
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type MdNode = string | MdElement;
|
|
2
|
+
export interface MdElement {
|
|
3
|
+
tag: string;
|
|
4
|
+
attrs: Record<string, string>;
|
|
5
|
+
children: MdNode[];
|
|
6
|
+
}
|
|
7
|
+
export interface Renderers<T, C = T[]> {
|
|
8
|
+
bold?: (children: C) => T | undefined;
|
|
9
|
+
italic?: (children: C) => T | undefined;
|
|
10
|
+
link?: (children: C, url: string) => T | undefined;
|
|
11
|
+
button?: (children: C, attrs: Record<string, string>) => T | undefined;
|
|
12
|
+
html?: (node: {
|
|
13
|
+
tag: string;
|
|
14
|
+
attrs: Record<string, string>;
|
|
15
|
+
children: C;
|
|
16
|
+
}) => T | undefined;
|
|
17
|
+
}
|
|
18
|
+
export interface Backend<T, C = T[]> {
|
|
19
|
+
element(tag: string, attrs: Record<string, string>, children: C): T;
|
|
20
|
+
/** Wrap a plain-text run. */
|
|
21
|
+
text(value: string): T;
|
|
22
|
+
/** Combine a rendered sibling list into the value passed as `children`. */
|
|
23
|
+
combine(nodes: T[]): C;
|
|
24
|
+
/** Optional: tag an array item (e.g. assign React/Preact keys). */
|
|
25
|
+
key?(node: T, index: number): T;
|
|
26
|
+
}
|
|
27
|
+
/** Parse a string into a plain node tree */
|
|
28
|
+
export declare function parse(text: string): MdNode[];
|
|
29
|
+
/** Bind an `md(text, renderers)` function to a rendering backend. */
|
|
30
|
+
export declare function createMarkdown<T, C = T[]>(backend: Backend<T, C>): (text: string | null | undefined, renderers?: Renderers<T, C>) => C;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Framework-agnostic core: parse strings into a node tree, then render that
|
|
2
|
+
// tree through a small backend. No runtime dependencies.
|
|
3
|
+
//
|
|
4
|
+
// Syntax (a deliberately tiny engine, not CommonMark):
|
|
5
|
+
// *bold* -> strong
|
|
6
|
+
// _italic_ -> em
|
|
7
|
+
// [text](url) -> a (href)
|
|
8
|
+
// <tag attr="…">…</tag> any paired inline tag (valid XML-like: no nesting,
|
|
9
|
+
// no self-closing, quoted attribute values)
|
|
10
|
+
const ATTR = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
11
|
+
function parseAttrs(raw) {
|
|
12
|
+
const attrs = {};
|
|
13
|
+
for (const m of raw.matchAll(ATTR)) {
|
|
14
|
+
attrs[m[1]] = m[2] ?? m[3];
|
|
15
|
+
}
|
|
16
|
+
return attrs;
|
|
17
|
+
}
|
|
18
|
+
const RULES = [
|
|
19
|
+
{
|
|
20
|
+
re: /\[([^\]]+)\]\(([^)]*)\)/,
|
|
21
|
+
parse: (m) => ({ tag: 'a', attrs: { href: m[2] }, inner: m[1] }),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
re: /\*([^*\n]+?)\*/,
|
|
25
|
+
parse: (m) => ({ tag: 'strong', attrs: {}, inner: m[1] }),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
re: /_([^_\n]+?)_/,
|
|
29
|
+
parse: (m) => ({ tag: 'em', attrs: {}, inner: m[1] }),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
re: /<([a-zA-Z][\w-]*)((?:\s+[\w-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*>([^<]*)<\/\1\s*>/,
|
|
33
|
+
parse: (m) => ({ tag: m[1], attrs: parseAttrs(m[2]), inner: m[3] }),
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
/** Parse a string into a plain node tree */
|
|
37
|
+
export function parse(text) {
|
|
38
|
+
const nodes = [];
|
|
39
|
+
let rest = text;
|
|
40
|
+
while (rest) {
|
|
41
|
+
// Earliest match wins; ties favour rule order (link > bold > italic > tag).
|
|
42
|
+
let best = null;
|
|
43
|
+
for (const rule of RULES) {
|
|
44
|
+
const m = rule.re.exec(rest);
|
|
45
|
+
if (m && (best === null || m.index < best.m.index))
|
|
46
|
+
best = { rule, m };
|
|
47
|
+
}
|
|
48
|
+
if (best === null) {
|
|
49
|
+
nodes.push(rest);
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
const { rule, m } = best;
|
|
53
|
+
if (m.index > 0)
|
|
54
|
+
nodes.push(rest.slice(0, m.index));
|
|
55
|
+
const { tag, attrs, inner } = rule.parse(m);
|
|
56
|
+
nodes.push({ tag, attrs, children: parse(inner) });
|
|
57
|
+
rest = rest.slice(m.index + m[0].length);
|
|
58
|
+
}
|
|
59
|
+
return nodes;
|
|
60
|
+
}
|
|
61
|
+
function renderElement(el, children, b, r) {
|
|
62
|
+
const { tag, attrs } = el;
|
|
63
|
+
let out;
|
|
64
|
+
switch (tag) {
|
|
65
|
+
case 'a':
|
|
66
|
+
out = r.link?.(children, attrs.href ?? '');
|
|
67
|
+
break;
|
|
68
|
+
case 'strong':
|
|
69
|
+
out = r.bold?.(children);
|
|
70
|
+
break;
|
|
71
|
+
case 'em':
|
|
72
|
+
out = r.italic?.(children);
|
|
73
|
+
break;
|
|
74
|
+
case 'button':
|
|
75
|
+
out = r.button?.(children, attrs);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (out === undefined)
|
|
79
|
+
out = r.html?.({ tag, attrs, children });
|
|
80
|
+
return out === undefined ? b.element(tag, attrs, children) : out;
|
|
81
|
+
}
|
|
82
|
+
function renderList(nodes, b, r) {
|
|
83
|
+
const items = nodes.map((node) => typeof node === 'string'
|
|
84
|
+
? b.text(node)
|
|
85
|
+
: renderElement(node, renderList(node.children, b, r), b, r));
|
|
86
|
+
const { key } = b;
|
|
87
|
+
return b.combine(key ? items.map((n, i) => key(n, i)) : items);
|
|
88
|
+
}
|
|
89
|
+
/** Bind an `md(text, renderers)` function to a rendering backend. */
|
|
90
|
+
export function createMarkdown(backend) {
|
|
91
|
+
return (text, renderers = {}) => renderList(parse(text ?? ''), backend, renderers);
|
|
92
|
+
}
|
package/dist/dom.d.ts
ADDED
package/dist/dom.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createMarkdown } from './core.js';
|
|
2
|
+
const backend = {
|
|
3
|
+
element: (tag, attrs, children) => {
|
|
4
|
+
const el = document.createElement(tag);
|
|
5
|
+
for (const name in attrs)
|
|
6
|
+
el.setAttribute(name, attrs[name]);
|
|
7
|
+
for (const child of children)
|
|
8
|
+
el.appendChild(child);
|
|
9
|
+
return el;
|
|
10
|
+
},
|
|
11
|
+
text: (value) => document.createTextNode(value),
|
|
12
|
+
combine: (nodes) => nodes,
|
|
13
|
+
};
|
|
14
|
+
export const md = createMarkdown(backend);
|
package/dist/html.d.ts
ADDED
package/dist/html.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createMarkdown } from './core.js';
|
|
2
|
+
const ESC_TEXT = { '&': '&', '<': '<', '>': '>' };
|
|
3
|
+
const ESC_ATTR = { '&': '&', '"': '"' };
|
|
4
|
+
const escapeText = (s) => s.replace(/[&<>]/g, (c) => ESC_TEXT[c]);
|
|
5
|
+
const escapeAttr = (s) => s.replace(/[&"]/g, (c) => ESC_ATTR[c]);
|
|
6
|
+
// C = string: children arrive already joined, so `${children}` just works.
|
|
7
|
+
const backend = {
|
|
8
|
+
element: (tag, attrs, children) => {
|
|
9
|
+
const a = Object.keys(attrs)
|
|
10
|
+
.map((k) => ` ${k}="${escapeAttr(attrs[k])}"`)
|
|
11
|
+
.join('');
|
|
12
|
+
return `<${tag}${a}>${children}</${tag}>`;
|
|
13
|
+
},
|
|
14
|
+
text: (value) => escapeText(value),
|
|
15
|
+
combine: (nodes) => nodes.join(''),
|
|
16
|
+
};
|
|
17
|
+
export const md = createMarkdown(backend);
|
package/dist/preact.d.ts
ADDED
package/dist/preact.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { cloneElement, h, isValidElement } from 'preact';
|
|
2
|
+
import { createMarkdown } from './core.js';
|
|
3
|
+
const backend = {
|
|
4
|
+
element: (tag, attrs, children) => h(tag, attrs, children.length ? children : null),
|
|
5
|
+
text: (value) => value,
|
|
6
|
+
combine: (nodes) => nodes,
|
|
7
|
+
// Stringify: Preact's cloneElement does `props.key || vnode.key`, so a
|
|
8
|
+
// falsy numeric key (index 0) is silently dropped otherwise.
|
|
9
|
+
key: (node, index) => isValidElement(node) && node.key == null
|
|
10
|
+
? cloneElement(node, { key: String(index) })
|
|
11
|
+
: node,
|
|
12
|
+
};
|
|
13
|
+
export const md = createMarkdown(backend);
|
package/dist/react.d.ts
ADDED
package/dist/react.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { cloneElement, createElement, isValidElement } from 'react';
|
|
2
|
+
import { createMarkdown } from './core.js';
|
|
3
|
+
const backend = {
|
|
4
|
+
element: (tag, attrs, children) => createElement(tag, attrs, children.length ? children : undefined),
|
|
5
|
+
text: (value) => value,
|
|
6
|
+
combine: (nodes) => nodes,
|
|
7
|
+
key: (node, index) => isValidElement(node) && node.key == null ? cloneElement(node, { key: index }) : node,
|
|
8
|
+
};
|
|
9
|
+
export const md = createMarkdown(backend);
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sidvishnoi/imd",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Tiny inline-markdown renderer for i18n strings, with pluggable backends.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"markdown",
|
|
7
|
+
"i18n",
|
|
8
|
+
"inline",
|
|
9
|
+
"react",
|
|
10
|
+
"preact",
|
|
11
|
+
"dom",
|
|
12
|
+
"html"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
"./core": {
|
|
26
|
+
"types": "./dist/core.d.ts",
|
|
27
|
+
"default": "./dist/core.js"
|
|
28
|
+
},
|
|
29
|
+
"./react": {
|
|
30
|
+
"types": "./dist/react.d.ts",
|
|
31
|
+
"default": "./dist/react.js"
|
|
32
|
+
},
|
|
33
|
+
"./preact": {
|
|
34
|
+
"types": "./dist/preact.d.ts",
|
|
35
|
+
"default": "./dist/preact.js"
|
|
36
|
+
},
|
|
37
|
+
"./dom": {
|
|
38
|
+
"types": "./dist/dom.d.ts",
|
|
39
|
+
"default": "./dist/dom.js"
|
|
40
|
+
},
|
|
41
|
+
"./html": {
|
|
42
|
+
"types": "./dist/html.d.ts",
|
|
43
|
+
"default": "./dist/html.js"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"check": "biome check .",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"format": "biome format --write .",
|
|
52
|
+
"lint": "biome lint .",
|
|
53
|
+
"lint:fix": "biome lint --write .",
|
|
54
|
+
"clean": "rm -rf dist",
|
|
55
|
+
"build": "tsc -p tsconfig.build.json",
|
|
56
|
+
"prepublishOnly": "pnpm clean && pnpm build"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"preact": ">=10",
|
|
60
|
+
"react": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"react": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"preact": {
|
|
67
|
+
"optional": true
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@biomejs/biome": "^2.5.3",
|
|
72
|
+
"@types/jsdom": "^28.0.3",
|
|
73
|
+
"@types/react": "^19.0.0",
|
|
74
|
+
"@types/react-dom": "^19.2.3",
|
|
75
|
+
"jsdom": "^29.1.1",
|
|
76
|
+
"preact": "^10.29.7",
|
|
77
|
+
"react": "^19.2.7",
|
|
78
|
+
"react-dom": "^19.2.7",
|
|
79
|
+
"typescript": "^7.0.2",
|
|
80
|
+
"vitest": "^4.1.10"
|
|
81
|
+
},
|
|
82
|
+
"packageManager": "pnpm@11.13.0",
|
|
83
|
+
"publishConfig": {
|
|
84
|
+
"access": "public"
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Framework-agnostic core: parse strings into a node tree, then render that
|
|
2
|
+
// tree through a small backend. No runtime dependencies.
|
|
3
|
+
//
|
|
4
|
+
// Syntax (a deliberately tiny engine, not CommonMark):
|
|
5
|
+
// *bold* -> strong
|
|
6
|
+
// _italic_ -> em
|
|
7
|
+
// [text](url) -> a (href)
|
|
8
|
+
// <tag attr="…">…</tag> any paired inline tag (valid XML-like: no nesting,
|
|
9
|
+
// no self-closing, quoted attribute values)
|
|
10
|
+
|
|
11
|
+
export type MdNode = string | MdElement;
|
|
12
|
+
|
|
13
|
+
export interface MdElement {
|
|
14
|
+
tag: string;
|
|
15
|
+
attrs: Record<string, string>;
|
|
16
|
+
children: MdNode[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Per-element render overrides. Return `undefined` to fall through.
|
|
20
|
+
//
|
|
21
|
+
// `T` is one rendered node; `C` is how a run of children is combined for a
|
|
22
|
+
// backend (an array for React/Preact/DOM, a joined string for HTML).
|
|
23
|
+
export interface Renderers<T, C = T[]> {
|
|
24
|
+
bold?: (children: C) => T | undefined;
|
|
25
|
+
italic?: (children: C) => T | undefined;
|
|
26
|
+
link?: (children: C, url: string) => T | undefined;
|
|
27
|
+
button?: (children: C, attrs: Record<string, string>) => T | undefined;
|
|
28
|
+
html?: (node: { tag: string; attrs: Record<string, string>; children: C }) => T | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Turns tag/attrs/children into target-specific nodes. One per framework.
|
|
32
|
+
export interface Backend<T, C = T[]> {
|
|
33
|
+
element(tag: string, attrs: Record<string, string>, children: C): T;
|
|
34
|
+
/** Wrap a plain-text run. */
|
|
35
|
+
text(value: string): T;
|
|
36
|
+
/** Combine a rendered sibling list into the value passed as `children`. */
|
|
37
|
+
combine(nodes: T[]): C;
|
|
38
|
+
/** Optional: tag an array item (e.g. assign React/Preact keys). */
|
|
39
|
+
key?(node: T, index: number): T;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Parsed {
|
|
43
|
+
tag: string;
|
|
44
|
+
attrs: Record<string, string>;
|
|
45
|
+
inner: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Rule {
|
|
49
|
+
re: RegExp;
|
|
50
|
+
parse: (m: RegExpExecArray) => Parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ATTR = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
54
|
+
|
|
55
|
+
function parseAttrs(raw: string): Record<string, string> {
|
|
56
|
+
const attrs: Record<string, string> = {};
|
|
57
|
+
for (const m of raw.matchAll(ATTR)) {
|
|
58
|
+
attrs[m[1]] = m[2] ?? m[3];
|
|
59
|
+
}
|
|
60
|
+
return attrs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const RULES: Rule[] = [
|
|
64
|
+
{
|
|
65
|
+
re: /\[([^\]]+)\]\(([^)]*)\)/,
|
|
66
|
+
parse: (m) => ({ tag: 'a', attrs: { href: m[2] }, inner: m[1] }),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
re: /\*([^*\n]+?)\*/,
|
|
70
|
+
parse: (m) => ({ tag: 'strong', attrs: {}, inner: m[1] }),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
re: /_([^_\n]+?)_/,
|
|
74
|
+
parse: (m) => ({ tag: 'em', attrs: {}, inner: m[1] }),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
re: /<([a-zA-Z][\w-]*)((?:\s+[\w-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*>([^<]*)<\/\1\s*>/,
|
|
78
|
+
parse: (m) => ({ tag: m[1], attrs: parseAttrs(m[2]), inner: m[3] }),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Parse a string into a plain node tree */
|
|
83
|
+
export function parse(text: string): MdNode[] {
|
|
84
|
+
const nodes: MdNode[] = [];
|
|
85
|
+
let rest = text;
|
|
86
|
+
|
|
87
|
+
while (rest) {
|
|
88
|
+
// Earliest match wins; ties favour rule order (link > bold > italic > tag).
|
|
89
|
+
let best: { rule: Rule; m: RegExpExecArray } | null = null;
|
|
90
|
+
for (const rule of RULES) {
|
|
91
|
+
const m = rule.re.exec(rest);
|
|
92
|
+
if (m && (best === null || m.index < best.m.index)) best = { rule, m };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (best === null) {
|
|
96
|
+
nodes.push(rest);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { rule, m } = best;
|
|
101
|
+
if (m.index > 0) nodes.push(rest.slice(0, m.index));
|
|
102
|
+
const { tag, attrs, inner } = rule.parse(m);
|
|
103
|
+
nodes.push({ tag, attrs, children: parse(inner) });
|
|
104
|
+
rest = rest.slice(m.index + m[0].length);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return nodes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderElement<T, C>(el: MdElement, children: C, b: Backend<T, C>, r: Renderers<T, C>): T {
|
|
111
|
+
const { tag, attrs } = el;
|
|
112
|
+
|
|
113
|
+
let out: T | undefined;
|
|
114
|
+
switch (tag) {
|
|
115
|
+
case 'a':
|
|
116
|
+
out = r.link?.(children, attrs.href ?? '');
|
|
117
|
+
break;
|
|
118
|
+
case 'strong':
|
|
119
|
+
out = r.bold?.(children);
|
|
120
|
+
break;
|
|
121
|
+
case 'em':
|
|
122
|
+
out = r.italic?.(children);
|
|
123
|
+
break;
|
|
124
|
+
case 'button':
|
|
125
|
+
out = r.button?.(children, attrs);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (out === undefined) out = r.html?.({ tag, attrs, children });
|
|
130
|
+
return out === undefined ? b.element(tag, attrs, children) : out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderList<T, C>(nodes: MdNode[], b: Backend<T, C>, r: Renderers<T, C>): C {
|
|
134
|
+
const items = nodes.map((node) =>
|
|
135
|
+
typeof node === 'string'
|
|
136
|
+
? b.text(node)
|
|
137
|
+
: renderElement(node, renderList(node.children, b, r), b, r),
|
|
138
|
+
);
|
|
139
|
+
const { key } = b;
|
|
140
|
+
return b.combine(key ? items.map((n, i) => key(n, i)) : items);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Bind an `md(text, renderers)` function to a rendering backend. */
|
|
144
|
+
export function createMarkdown<T, C = T[]>(backend: Backend<T, C>) {
|
|
145
|
+
return (text: string | null | undefined, renderers: Renderers<T, C> = {}): C =>
|
|
146
|
+
renderList(parse(text ?? ''), backend, renderers);
|
|
147
|
+
}
|
package/src/dom.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Backend, createMarkdown, type Renderers } from './core.js';
|
|
2
|
+
|
|
3
|
+
const backend: Backend<Node> = {
|
|
4
|
+
element: (tag, attrs, children) => {
|
|
5
|
+
const el = document.createElement(tag);
|
|
6
|
+
for (const name in attrs) el.setAttribute(name, attrs[name]);
|
|
7
|
+
for (const child of children) el.appendChild(child);
|
|
8
|
+
return el;
|
|
9
|
+
},
|
|
10
|
+
text: (value) => document.createTextNode(value),
|
|
11
|
+
combine: (nodes) => nodes,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const md = createMarkdown(backend);
|
|
15
|
+
export type { Renderers };
|
package/src/html.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Backend, createMarkdown, type Renderers } from './core.js';
|
|
2
|
+
|
|
3
|
+
const ESC_TEXT: Record<string, string> = { '&': '&', '<': '<', '>': '>' };
|
|
4
|
+
const ESC_ATTR: Record<string, string> = { '&': '&', '"': '"' };
|
|
5
|
+
const escapeText = (s: string) => s.replace(/[&<>]/g, (c) => ESC_TEXT[c]);
|
|
6
|
+
const escapeAttr = (s: string) => s.replace(/[&"]/g, (c) => ESC_ATTR[c]);
|
|
7
|
+
|
|
8
|
+
// C = string: children arrive already joined, so `${children}` just works.
|
|
9
|
+
const backend: Backend<string, string> = {
|
|
10
|
+
element: (tag, attrs, children) => {
|
|
11
|
+
const a = Object.keys(attrs)
|
|
12
|
+
.map((k) => ` ${k}="${escapeAttr(attrs[k])}"`)
|
|
13
|
+
.join('');
|
|
14
|
+
return `<${tag}${a}>${children}</${tag}>`;
|
|
15
|
+
},
|
|
16
|
+
text: (value) => escapeText(value),
|
|
17
|
+
combine: (nodes) => nodes.join(''),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const md = createMarkdown(backend);
|
|
21
|
+
export type { Renderers };
|
package/src/preact.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ComponentChild, cloneElement, h, isValidElement, type VNode } from 'preact';
|
|
2
|
+
import { type Backend, createMarkdown, type Renderers } from './core.js';
|
|
3
|
+
|
|
4
|
+
const backend: Backend<ComponentChild> = {
|
|
5
|
+
element: (tag, attrs, children) => h(tag, attrs, children.length ? children : null),
|
|
6
|
+
text: (value) => value,
|
|
7
|
+
combine: (nodes) => nodes,
|
|
8
|
+
// Stringify: Preact's cloneElement does `props.key || vnode.key`, so a
|
|
9
|
+
// falsy numeric key (index 0) is silently dropped otherwise.
|
|
10
|
+
key: (node, index) =>
|
|
11
|
+
isValidElement(node) && (node as VNode).key == null
|
|
12
|
+
? cloneElement(node as VNode, { key: String(index) })
|
|
13
|
+
: node,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const md = createMarkdown(backend);
|
|
17
|
+
export type { Renderers };
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { cloneElement, createElement, isValidElement, type ReactNode } from 'react';
|
|
2
|
+
import { type Backend, createMarkdown, type Renderers } from './core.js';
|
|
3
|
+
|
|
4
|
+
const backend: Backend<ReactNode> = {
|
|
5
|
+
element: (tag, attrs, children) =>
|
|
6
|
+
createElement(tag, attrs, children.length ? children : undefined),
|
|
7
|
+
text: (value) => value,
|
|
8
|
+
combine: (nodes) => nodes,
|
|
9
|
+
key: (node, index) =>
|
|
10
|
+
isValidElement(node) && node.key == null ? cloneElement(node, { key: index }) : node,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const md = createMarkdown(backend);
|
|
14
|
+
export type { Renderers };
|