Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Markdown-lite

A small, deliberately non-standard "Markdown-lite" syntax and the plain function that renders it, formatRichText. It's what eun-textarea's formatting 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 Lit 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 Textarea's own Accessibility tab for the full detail.

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 Lit template from that tree: every piece of actual text content still goes through Lit's own normal, automatically-escaping template interpolation. 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