DEV Community

Cover image for How I actually built ng-text-editor-lite, and what to think about if you're building your own
Ankitkumar Singh
Ankitkumar Singh

Posted on AI-assisted

How I actually built ng-text-editor-lite, and what to think about if you're building your own

The final part of the ng-text-editor-lite trilogy. Part 1 was why I built it. Part 2 was what broke in production. This one is how the thing actually works, and what you should think about if you want to ship your own Angular library.

Two blog posts in, and I've spent the whole series talking around the editor without really opening the hood. That was on purpose. The first two parts were about outcomes: why the package exists, and how it behaves once installed. But if you're reading this, there's a decent chance you're either considering building your own text editor, thinking about publishing your own Angular library, or just curious about the decisions that shape a small package like this.

So this is the technical retrospective. Grouped by concern rather than by chronology, because in real building the concerns overlap and interact more than any timeline suggests. I'm also weaving in the packaging side, because "how you build an editor" and "how you ship an Angular library on npm" are two different problems that most first-time library authors underestimate.


Rendering: why contenteditable, not a virtual DOM

The first big decision was the rendering model. There are two established approaches to text editors on the web:

  1. Virtual DOM editors: ProseMirror, Slate, Quill, Lexical. You build a data model, transform it with commands, render the DOM yourself. You control everything, but you also implement everything, including selection, cursor movement, and IME composition.
  2. Native contenteditable: use the browser's built-in contenteditable attribute and let the browser handle the editing surface. You get selection, cursor, keyboard shortcuts, IME, and mobile keyboards for free. You give up fine-grained control over the DOM structure and have to sanitize aggressively on the way out.

Every editor above 25 kB uses approach 1. Every editor below uses approach 2. The reason is simple: implementing the hard parts (selection, IME, mobile input) is what costs the kilobytes.

For a task description field, I didn't need fine-grained control. I needed the browser to do the boring work, and I needed to control what got saved. So contenteditable was the obvious choice.

The tradeoff I accepted: cross-browser DOM output varies. Chrome might insert a <div> on Enter. Firefox might insert a <br>. Safari does its own thing with paste. You end up writing more normalization code than you'd expect, and you learn to distrust anything that comes out of document.execCommand.


What to think about if you're building your own

Should you build it?
The first decision. Not every "I'll just build it myself" ends well. Walk this before you write a line of code.

Security: sanitization is a boundary, not a filter

This one I refuse to be relaxed about. contenteditable accepts anything the user pastes, and the user might paste HTML from a malicious source without knowing it. Every input path in a rich editor is an XSS target.

The security model in ng-text-editor-lite has one rule: no HTML string reaches the DOM without passing through SanitizerService.sanitize() first. Every single entry point runs through it:

  • [content] input binding
  • Clipboard paste, both plain text and HTML
  • Markdown paste, after conversion to HTML
  • Programmatic writes through ControlValueAccessor.writeValue

There's no bypass. Not even for internal code. If a new code path needs to insert HTML, it goes through the sanitizer or it doesn't ship.

The sanitizer wraps DOMPurify with an explicit configuration:

DOMPurify.sanitize(html, {
  ALLOWED_TAGS: ['h1','h2','p','strong','em','s','a','ul','ol','li','br','span'],
  ALLOWED_ATTR: ['href','target','rel','class'],
  ALLOW_DATA_ATTR: false,
  FORBID_TAGS: ['script','style','iframe','object','embed'],
});
Enter fullscreen mode Exit fullscreen mode

A post-sanitization hook enforces the link policy: every anchor gets target="_blank" and rel="noopener noreferrer", and any href starting with javascript:, data:, or vbscript: has the attribute stripped.

What to think about if you're building your own: don't roll your own HTML sanitizer. DOMPurify is battle-tested and maintained by people who spend more time thinking about XSS than you do. Wrap it, don't replace it. And treat the sanitizer boundary as a contract, not a helper. If you have "sanitize sometimes" logic anywhere in your codebase, you don't have a security model.


The paste pipeline: order matters

Paste is the most complex event the editor handles. Here's the pipeline, in order:

  1. paste event fires
  2. Read the clipboard's plain text representation, not HTML yet
  3. Run a markdown detection pass on the plain text
  4. If markdown syntax is detected, route through MarkdownParserService to convert to HTML
  5. If not, fall back to the clipboard's HTML payload
  6. Sanitize the resulting HTML through SanitizerService
  7. Insert into the DOM at the current selection

The critical decision is the order. Detection happens on plain text first, not HTML. Why? Because when you copy from ChatGPT or Notion, the clipboard has both a text/plain and a text/html representation. The HTML version is often wrapped in vendor-specific structure (Notion adds a lot). The plain text version is the clean markdown source. That's what your user actually copied.

If detection ran on the HTML, it would rarely find markdown syntax, because the HTML would already be rendered. Running it on plain text first means the markdown-from-AI-tools case just works.

What to think about if you're building your own: paste handling has more edge cases than you'd expect. Word documents paste as inline styles. Google Docs pastes with vendor prefixes. Slack pastes emoji as images. Whatever you build, test with real content from the tools your users actually use, not Lorem Ipsum.


The markdown parser I wrote by hand

I could have pulled in marked (43 kB minified) or markdown-it (95 kB minified). Both are excellent. Neither made sense here.

My editor supports six markdown constructs: h1, h2, bold, italic, links, and lists. That's a couple hundred lines of regex plus some inline combination handling. Adding a full markdown library to support six constructs would have blown the bundle budget on day one.

So I wrote a small parser. It handles the supported subset and passes everything else through as plain text. Unsupported syntax doesn't crash, doesn't corrupt state, just renders as-is. That's a deliberate design decision: silent fallback beats loud failure for content the user didn't ask to have transformed.

What to think about if you're building your own: if you're supporting a small subset of a spec, write it yourself. If you're supporting most of a spec, use the established library. The break-even point is roughly: if your parser is under 300 lines, roll your own. If you'd need more than that, the library is cheaper.


Styling: isolation as a design principle

The editor's CSS has one job beyond looking correct: don't let anything else break it, and don't break anything else.

Two mechanisms handle this:

  1. all: unset on the editable surface. This nukes any inherited styles from Bootstrap, Tailwind, Material, or your global reset. Whatever the parent app applies, the editor's own typography wins inside its content area.
  2. Scoped selectors everywhere else. Every CSS rule is prefixed with .ngx-editor-lite, so nothing leaks outward into your app.

For theming, I chose CSS custom properties written as inline styles on the host element. This has one huge advantage and one honest tradeoff.

The advantage: the editor's theme cannot be broken by external CSS resets. Inline styles beat any external rule that doesn't use !important, so a Tailwind Preflight or a Material theme override can't accidentally paint the editor the wrong color.

The tradeoff (which part 2 explored in detail): overriding those inline styles from your own CSS requires !important. That's ugly, and if I did it again, I'd expose a full colors object on the config instead of relying on CSS overrides. It's the one API decision I'd revisit.

What to think about if you're building your own: style isolation is worth engineering for. all: unset is your friend on the editable surface. But think carefully about your override story before you commit to inline styles as the theme mechanism.


Angular library packaging

Building the editor was half the work. Packaging it as a proper Angular library was the other half.

The project structure follows the Angular CLI's library convention:

projects/
  ng-text-editor-lite/     ← the library source
    src/lib/               ← components, services, models
    src/public-api.ts      ← the export surface
  demo/                    ← a local playground app
Enter fullscreen mode Exit fullscreen mode

The public-api.ts file is what your users import from. Only things listed there are part of the public API. Everything else is internal, even if it's technically exported by TypeScript. Being disciplined about this file prevents you from accidentally shipping internal utilities and being on the hook for their behavior forever.

Two Angular-specific decisions matter here:

Standalone components as the primitive. The library ships one exported component: EditorComponent, standalone. No NgModule. This makes tree-shaking cleaner (users import exactly what they use) and keeps the API surface tiny. Angular 14+ supports standalone components, and 18+ makes them idiomatic.

Peer dependencies, not regular dependencies. The package.json lists Angular and DOMPurify as peer dependencies. This means your library uses whichever version the host app has installed, rather than bundling its own. Getting this wrong is how you end up with two copies of Angular in a bundle and a broken app.

What to think about if you're building your own: use ng-packagr, which comes with the Angular CLI's library generator. Publish only what you want to support long-term. Every dependency you're tempted to add should be a peer dependency unless you have a specific reason it can't be.


Bundle discipline

The 5.2 kB gzipped budget was the constraint that shaped every other decision. I want to be honest: staying under that budget was a constant fight, and I made compromises for it.

The rules I followed:

  • No lodash. Not even one function. Small utilities are inline.
  • No additional RxJS. The library uses only what Angular already ships. No new operators, no new subjects unless absolutely required.
  • No CSS-in-JS. Plain SCSS compiled to CSS.
  • One peer dependency, DOMPurify. Every other dependency request was refused.
  • Measure every change. source-map-explorer on the built bundle after every meaningful commit. If the number went up, either the feature justified it or the code got reverted.

What to think about if you're building your own: set a budget before you start, not after. Once features are in, cutting them for size becomes a fight with your own past self. The number is easier to defend when it's a constraint, not a target.


What I deliberately left out

The hardest engineering discipline in this project wasn't writing the code. It was saying no.

The features I chose not to build, and would still refuse today:

  • Image uploads. Every user asks for this. It doubles the bundle, complicates the API, and pulls you into hosting, resizing, and storage decisions that don't belong in a text editor.
  • Code blocks. Requires syntax highlighting (30+ kB minimum with any library), and if you skip highlighting, users complain about the visual quality.
  • Tables. Contenteditable tables are a nightmare of cross-browser bugs. Every hour spent on them is an hour not spent on the core features.
  • Plugin system. A plugin API adds surface area, versioning obligations, and breaks the "small, focused" promise. If you need plugins, you need one of the heavy editors.

Every one of these has a version of "just add it" that seems reasonable in isolation. The compounding effect of saying yes to all of them is a 100 kB editor that does everything the heavy options already do, but worse.

What to think about if you're building your own: write down your non-goals before you start, and re-read them every time someone asks for a feature. The value of a small package is that it stays small.


Where the trilogy lands

Three posts in, here's what I hope you take away.

From part 1: the case for building your own was made honestly. Not every app needs a 200 kB editor. Sometimes 5 kB is the right answer, and the only way to get there is to build it. Read here

From part 2: shipping a package to npm is not the finish line. Real usage will find edge cases the code and the docs missed. The fixes are usually small, but only if you're paying attention. Read here

From part 3: the engineering decisions inside a small library are as much about what you refuse to build as what you build. Every "no" is a feature. Every constraint is a design choice.


Try it, break it, build your own

ng-text-editor-lite is on npm at @thedevankit/ng-text-editor-lite. Source, issues, and PRs live on the linked GitHub repo.

If you're thinking about building your own Angular library, whether it's a text editor or something else entirely, the biggest thing I can tell you is this: start with the smallest thing that solves the actual problem. Ship it. See what breaks. Fix that. Repeat. That's the whole process. There's no shortcut, and the shortcut you think you've found usually adds 40 kB to your bundle.

Thanks for reading the series. If any of it helped you make a decision, save an afternoon, or ship something of your own, a star on the repo or a reaction on the post is the cheapest way you can tell me open source work landed.GitHub repo.

Top comments (0)