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";
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 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.
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.
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.
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.
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