Markdown-lite
A small, deliberately non-standard "Markdown-lite" syntax and the plain
function that renders it, formatRichText. It's what
eun-textareaformatting toolbar reads and writes under the hood (its value is always
this syntax, and its readonly-available static display renders it through
this exact function), but the function itself has no dependency on
eun-textarea at all: it's a plain, framework-agnostic export, useful
anywhere you need to render this syntax, whether that's a comment stored
from a eun-textarea and displayed somewhere else entirely, a value read
back from an API, or anything else.
npm install @eunomia/elements
import { formatRichText } from "@eunomia/elements";
The helpers are also published as their own entry point, which registers no custom element and pulls in none of the components, so it's the one to reach for when you only want the formatter (a server-side module, a bundle that shouldn't load the whole library):
import {
formatRichText,
toHtml,
toPlainText,
} from "@eunomia/elements/rich-text.js";
Why "lite"
Real Markdown is a large, ambiguous, whitespace-sensitive spec built for
prose documents. This syntax solves one narrow problem instead: storing
exactly what eun-textarea's own formatting toolbar (Bold, Italic,
Underline, alignment, text color, highlight) can produce, as plain text, in
a way that's cheap to parse and impossible to inject through. It only
supports the constructs listed below, nothing else, on purpose.
Rendering
Rendering the syntax
function formatRichText(text: string): TemplateResult;
| Parameter | Type | Description |
|---|---|---|
text |
string |
The Markdown-lite string to render |
Returns a TemplateResult, not an HTML string, not
a DOM node. Render it with Lit's own render(), or interpolate it directly
inside another Lit component's own template (exactly how eun-textarea's
readonly-available display uses it):
import { render } from "lit";
import { formatRichText } from "@eunomia/elements";
render(
formatRichText("Please **confirm** your *email address*."),
document.querySelector("#output"),
);
// Inside another Lit component's own render():
import { formatRichText } from "@eunomia/elements";
render() {
return html`<div>${formatRichText(this.storedComment)}</div>`;
}
See "Why this is safe against injection" below for the full reasoning.
Rendering to an HTML string
function toHtml(text: string): string;
Same output as formatRichText, as a plain HTML string instead of a Lit
template: same <p>, <ul>, <strong>, <em>, <u> and <span> markup, no
DOM and no Lit needed. It runs anywhere JavaScript does, which makes it the
right tool for server rendering, where a Lit TemplateResult can't be turned
into markup without extra tooling. A Next.js Server Component, for example:
import { toHtml } from "@eunomia/elements/rich-text.js";
export function RichText({ text }: { text: string }) {
return <div dangerouslySetInnerHTML={{ __html: toHtml(text) }} />;
}
The string is safe to inject as is. See "Why this is safe against injection"
below for what toHtml escapes and which link destinations it accepts.
Syntax reference
Every example below is a live eun-textarea in its readonly-available +
formatting state: exactly formatRichText's own rendering, since that's
what powers this display. Click into any of them to see (and edit) the raw
Markdown-lite source underneath.
Bold, italic, underline
| Syntax | Renders as |
|---|---|
**text** |
bold |
*text* |
italic |
__text__ |
underlined (not standard Markdown, which has no native underline syntax, so this was chosen to visually mirror **/* while staying unambiguous) |
***text*** |
bold and italic together (the same convention real Markdown uses) |
Marks nest arbitrarily: **bold with *italic* inside** works, and so does
combining any of the above with color/highlight (see below). Order isn't
significant, since whichever wraps outermost in the source is just
whichever wraps outermost in the result.
Text color and highlight
| Syntax | Renders as |
|---|---|
{color=name}text{/color} |
text in that color |
{highlight=name}text{/highlight} |
text with that color as a background highlight |
name is one of a fixed, named palette, not an arbitrary hex value,
shared with eun-textarea's own color/highlight pickers so the two always
agree on what a given name looks like:
import { RICH_TEXT_COLORS, findRichTextColor } from "@eunomia/elements";
RICH_TEXT_COLORS.map((color) => color.name);
// ["red", "orange", "yellow", "green", "blue", "purple", "gray"]
findRichTextColor("red");
// { name: "red", label: "Red", text: "#d8291c", highlight: "#fbe0de" }
Reuse RICH_TEXT_COLORS/findRichTextColor to build your own UI that needs
to agree with this same palette (a legend, a custom picker, a color-coded
list) instead of hand-copying the hex values.
Links
| Syntax | Renders as |
|---|---|
[label](url) |
a real <a href="url">label</a> |
There's no toolbar button for this one: formatRichText (and
eun-textarea's readonly display) recognizes a link if it's already in the
value, but nothing in the formatting toolbar itself can create one.
Type it directly.
The rendered <a> ends up nested inside the static display's own role="button", which assistive technology exposes inconsistently. See
Lists
Consecutive lines starting with - become a single <ul>, flushed as
soon as a non-list line breaks the run (a second list right after starts a
new, separate <ul>). Each item's own text is parsed for inline syntax too
(bold/italic/color/etc., as in the **3** above). There's no numbered-list
syntax, and no nesting: one flat level only.
Alignment
| Syntax | Effect |
|---|---|
[align:start] |
Left-aligned, the default, typically omitted entirely |
[align:center] |
Centered |
[align:end] |
Right-aligned |
[align:justify] |
Justified |
Unlike every other construct above, alignment is a single, whole-value
setting, not an inline mark: it describes the entire text, not a span
within it. It's encoded as a directive on its own line, at the very start
of the string, followed by a newline before the actual content: [align:center]\nThe rest of the text. formatRichText strips it before
rendering the content itself. Reading it back (e.g. to set a
container's own text-align) is a separate, small step, covered by
splitAlign below.
Utilities
Reading and writing the alignment directive
import { splitAlign, joinAlign } from "@eunomia/elements";
splitAlign("[align:center]\nHello world");
// { align: "center", body: "Hello world" }
splitAlign("Hello world");
// { align: "start", body: "Hello world" } — no directive present
joinAlign("center", "Hello world");
// "[align:center]\nHello world"
joinAlign("start", "Hello world");
// "Hello world" — "start" is the default, so it's omitted entirely
formatRichText calls splitAlign internally (so the directive line never
renders as a literal paragraph), but doesn't otherwise expose the
alignment itself: call splitAlign yourself first if you need to also set
text-align on whatever element you're rendering into, exactly how
eun-textarea's own readonly display does it.
Measuring length without the markup
import { getPlainTextLength } from "@eunomia/elements";
getPlainTextLength("**hello** world");
// 11 — not 15 ; the ** markers aren't counted
getPlainTextLength("{color=red}hi{/color}");
// 2
getPlainTextLength("[align:center]\nHello");
// 5 — the directive line isn't counted either
The visible character count of a Markdown-lite string, every marker
stripped out: what eun-textarea's own character counter measures against
maxlength when formatting is on, so applying Bold/Italic/color never
silently eats into a user's remaining budget. Reach for this whenever you
need to display or enforce a length limit against this syntax yourself,
instead of .length-ing the raw marked-up string.
Extracting plain text
function toPlainText(text: string): string;
import { toPlainText } from "@eunomia/elements/rich-text.js";
toPlainText("**Free** delivery, see [our terms](/terms).");
// "Free delivery, see our terms."
toPlainText("[align:center]\nFirst line\n{color=red}Second{/color}");
// "First line\nSecond"
Every marker is removed, a link keeps its label, and one newline stays between
lines. It needs no DOM, so it works on the server, and it always agrees with
getPlainTextLength: toPlainText(text).length is the same number.
Use it wherever the markup would be noise, such as the text of a FAQPage
structured data block, a <meta name="description">, a search index or a
notification:
import { toPlainText } from "@eunomia/elements/rich-text.js";
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: faqs.map(({ question, answer }) => ({
"@type": "Question",
name: question,
acceptedAnswer: { "@type": "Answer", text: toPlainText(answer) },
})),
};
Why this is safe against injection
formatRichText parses text into a small tree first (bold, italic,
color, link, etc., each with its own nested children), then builds a real
unsafeHTML (or any string-concatenation-into-HTML
equivalent) is never used anywhere in this path. A value like
<img src=x onerror=alert(1)> embedded in otherwise-plain text renders as
inert, visible text, not a live <img> tag, exactly as if you'd
interpolated it into any other Lit template yourself.
toHtml builds its string from the same tree, but has no template engine to
escape for it, so it does that work itself: every piece of text and every
attribute value is HTML-escaped, and a link is only emitted as an <a> when
its destination is a relative path, a fragment, or uses http, https,
mailto or tel. A [label](javascript:...) link renders as its plain
label instead.
Things to know
- There's no escape syntax: a literal
**typed as ordinary text (not meant as bold) still gets parsed as one. This mirrors real Markdown's own well-known "asterisks are special, always" tradeoff, not something specific to this syntax. *(italic) is a strict substring of**(bold), so the parser checks**/__(and***for bold+italic together) before a lone*, in that order, specifically to avoid misreading one as the other.- Alignment is whole-value, not per-paragraph: there's no syntax for "this one paragraph, different alignment."
- Color/highlight names are the fixed palette above only, since an arbitrary hex isn't recognized syntax.
- Treating this as real Markdown, or feeding it to a real Markdown parser elsewhere: headings, code fences, blockquotes, numbered lists, tables, etc. simply aren't part of this syntax and won't render
- Hand-writing this syntax as a "stable format" for external interop (a CMS field, another product), since it's an implementation detail of
eun-textarea's formatting toolbar first, a documented convenience second
See also
Textarea : theformattingtoolbar that reads and writes this syntax, and its Accessibility tab for the full keyboard/ARIA details of that toolbarRate limiting :debounce/throttle, this page's siblingDrag and drop :createDragReorder, this page's other sibling