Traven

Developer Knowledgebase

A reference for developer onboarding and technical context for developers wanting to customize or expand the Traven WYSIWYM Markdown Editor.

1. Repository Architecture & Layout

Traven is designed to be a lightweight, framework-agnostic editor component. It is compiled to standard ES Modules (ESM) with zero runtime peer-dependencies:

  • package.json: Bundler settings (esbuild), script pipelines (npm run build/watch), and versioned CodeMirror 6 packages.
  • packages/core/src/index.js: Public API surface exposed via the TravenEditor class (mounts CodeMirror, encapsulates state lifecycle, inserts snippets, and fires events).
  • packages/core/assets/skins/: Decoupled stylesheets (skin-starter.css, skin-light.css, skin-colorful.css, skin-dark.css, skin-editorial.css, skin-modern.css, skin-academic.css, skin-custom.css) containing editor visual theme configurations that require no rebuilds.
  • packages/core/assets/toolbars/toolbar-default.css: Decoupled toolbar presentation stylesheet, letting users skin and toggle toolbar buttons independently.
  • packages/core/src/toolbar/: Modular toolbar components (toolbar.js, tools.js, modal.js) handling dynamic generation, accessibility behaviors, modals, and actions.
  • packages/core/src/wysiwym.js: Core decoration state machine mapping markdown parser nodes to collapsed replacement decorations.
  • packages/core/src/delimiter-skip.js: Arrow-key keyboard hook checking if cursors are on boundaries of folded syntax blocks to skip them.
  • packages/core/src/images.js: Drag-and-drop/paste optimistic file uploader mapping state adjustments.

2. Core Technical Findings & CodeMirror 6 Pitfalls

During initial visual scaffolding and integration, we resolved several critical constraints inherent to CodeMirror 6's layout engine. Maintain these practices for any future styling or behavior modifications:

A. Block Decorations inside ViewPlugins (RangeError)

  • Problem: In CodeMirror 6, block-level replacement decorations (e.g. Decoration.replace({ widget: ..., block: true }) or custom line widgets) cannot be returned directly from the decorations facet of a standard ViewPlugin. Attempting to do so triggers a RangeError: Block decorations may not be specified via plugins.
  • Fix: The decorations must be defined using a StateField and exposed to the editor view via the state field provider provide: (field) => EditorView.decorations.from(field). Both wysiwym.js and images.js use this pattern.

B. Accurate Click Targeting & Character Width Caching

When hiding elements or editing syntax markers (like ** or _), CodeMirror must know their rendered widths to map cursor clicks to correct character offsets.

  • Avoid CSS-based Hiding: Do not collapse character widths using CSS (font-size: 0, width: 0, or opacity: 0 on standard Decoration.mark elements). CodeMirror's coordinate mapping engine remains unaware of the collapsed size, making mouse clicks land offset from their visual target.
  • Use Native Replacements: Use Decoration.replace({}) to collapse syntax ranges. This natively informs CodeMirror's layout engine that the text range is replaced with an empty 0-width fragment.
  • Defer to Font Loading: CodeMirror measures and caches character widths on initialization. If fonts (like Google Fonts) load asynchronously after the editor renders, the width cache becomes invalid, causing cursor misalignment. Always initialize the editor inside a document.fonts.ready.then(...) block:
    document.fonts.ready.then(() => {
      new TravenEditor({ ... });
    });
    

C. Layout Measuring & Vertical Margins (Vertical Offsets)

  • Problem: Standard line elements in CodeMirror (.cm-line or line decorations) must be stacked sequentially. Applying vertical margins (margin-top / margin-bottom) to headings or blockquotes pushes lines apart in a way that CodeMirror's layout calculations do not measure, causing mouse clicks to land on the wrong lines entirely.
  • Fix: Never use vertical margins on line elements or inline widgets. Always use vertical paddings (padding-top/padding-bottom) to create vertical layout spacing:
    .cm-wysiwym-h1 {
      padding-top: 24px !important;
      padding-bottom: 12px !important;
      margin: 0 !important;
    }
    

D. RangeSetBuilder Sorting Constraint (startSide)

  • Problem: RangeSetBuilder.add requires that all ranges added are strictly ordered by from position ascending. If two ranges share the same from position, they must be added in order of their decoration's startSide ascending. Failing to do this triggers: Error: Ranges must be added sorted by from position and startSide.
  • Fix: When sorting decorations before building, use a multi-tiered comparator that compares from first, then deco.startSide (defaulting to 0 if not present), and finally to descending (larger range first).

E. Custom Lezer Inline Parsers (Delimiter Resolution)

  • API Pattern: To implement custom inline formatting or shortcode wrappers (like ==highlight==), utilize Lezer's cx.addDelimiter API rather than manually scanning/splitting strings.
  • Automatic Balancing: Register a delimiter object (e.g., { resolve: "Highlight", mark: "HighlightMark" }). Lezer's engine will automatically match opening/closing boundaries, parse inner content recursively, and wrap the matched region in the resolve node type.
  • Boundary Guards: Always check for consecutive characters to avoid false matches (e.g. check cx.char(pos + 2) == 61 to avoid parsing === as highlight delimiter ==).
  • Flanking Rules: For delimiter activation to behave predictably at word boundaries and punctuation, implement flanking validation (checking if the characters immediately preceding and succeeding the delimiter match whitespace or Unicode punctuation).
  • Styling: Maps resolved node names (HighlightMark, Highlight/...) to Lezer's tags inside the parser config props using styleTags, or use high-level custom CSS classes inside the decoration builder pipeline in wysiwym.js.

F. Lost Focus & Stale WYSIWYM Decorations (Measure Cycle Nudge)

  • Problem: In CodeMirror 6, view measurements and decoration-rendering cycles are driven by DOM events and the browser's paint loop. When a modal opens, it traps focus and moves it out of the CodeMirror editor. When closed, focus is returned to the triggerElement (the toolbar button) rather than the editor itself. In aggressive browsers (especially Firefox, which pauses requestAnimationFrame loops when focus moves outside the active DOM subtree), the editor remains in a stale state where WYSIWYM decorations disappear and raw Markdown is displayed until the user clicks inside the editor to trigger a new mousedown event.
  • Fix: When closing any modal or overlay that took focus away from the editor, perform a "nudge" to force CodeMirror to re-run its layout measure and decoration cycle. This is done by registering an onClose callback in the modal options, and calling view.requestMeasure() on the active CodeMirror view instance:
    openModal({
      title: "Insert Image",
      body: form,
      triggerElement: triggerBtn,
      onClose: () => {
        const view = editor.getView();
        if (view && typeof view.requestMeasure === "function") {
          view.requestMeasure();
        }
      },
      buttons: [ ... ]
    });
    

3. Sandboxed Resizing Rules

To prevent layout constraints on flexible split-screen dashboards:

  • Apply vertical resizability directly to the outer container card (resize: vertical; overflow: hidden;).
  • Configure the CodeMirror mount and editor container to scale proportionally to fill the card:
    .editor-mount {
      flex: 1;
      overflow: hidden;
    }
    .editor-mount .cm-editor {
      height: 100%;
    }
    

4. Toolbar Configuration & Button Customization

Traven features a modular, dynamic toolbar system. Instead of hardcoded HTML markup, the toolbar is programmatically constructed by buildToolbar based on a configuration array passed to the TravenEditor constructor.

Default Toolbar Layout

By default, Traven displays the following tools in order (separated by pipe | characters for vertical dividers):

  • undo / redo (History controls)
  • bold / italic / strikethrough / highlight / code / codeblock (Inline formatting)
  • heading (H1–H6 selection dropdown)
  • bulletlist / numberedlist / tasklist / blockquote / hr / table / component / figure (Block and special elements)
  • datetime / search / link / image / video / audio / fullscreen / clear / uppercase / lowercase / capitalize / removeformatting / gotoline / help (Utility functions)

This corresponds to the DEFAULT_TOOLBAR array defined in src/index.js.

Customizing the Toolbar Layout

To customize the order of buttons or hide specific ones, pass an array of tool keys to the toolbar property of the TravenEditor options during initialization:

new TravenEditor({
  element: document.getElementById("editor-container"),
  initialValue: "# Hello World",
  toolbar: ["undo", "redo", "|", "bold", "italic", "link"]
});

To completely disable the toolbar, pass toolbar: false.

List of Available Toolbar Buttons & CSS Identifiers

Each button generated in the toolbar is assigned the class .toolbar-btn and a unique identifier class corresponding to its tool key (e.g. .btn-bold). These can be targeted for custom styling or hiding.

Tool Key CSS Selector Action Description Default Keyboard Shortcut
undo .btn-undo Undo last operation Ctrl+Z (Cmd+Z on Mac)
redo .btn-redo Redo last undone operation Ctrl+Y (Cmd+Y on Mac)
bold .btn-bold Bold text (**bold text**) Ctrl+B (Cmd+B on Mac)
italic .btn-italic Italic text (*italic text*) Ctrl+I (Cmd+I on Mac)
strikethrough .btn-strikethrough Strikethrough text (~~strikethrough~~) Ctrl+Shift+S (Cmd+Shift+S on Mac)
highlight .btn-highlight Highlight text (==highlight==) -
code .btn-code Inline code backticks (`code`) -
heading .btn-heading Dropdown menu for Heading 1 to Heading 6 -
bulletlist .btn-bulletlist Unordered list formatting (- ) -
numberedlist .btn-numberedlist Ordered list formatting (1. ) -
tasklist .btn-tasklist Checklist formatting (- [ ] ) Ctrl+Shift+C (Cmd+Shift+C on Mac)
blockquote .btn-blockquote Blockquote formatting (> ) -
hr .btn-hr Horizontal rule line (---) -
codeblock .btn-codeblock Fenced code block (```) -
table .btn-table Insert table template via modal grid -
datetime .btn-datetime Insert current Date & Time -
search .btn-search Open CodeMirror search panel Ctrl+F (Cmd+F on Mac)
fullscreen .btn-fullscreen Toggle editor fullscreen mode -
clear .btn-clear Clear all document contents -
uppercase .btn-uppercase Convert selection to UPPERCASE -
lowercase .btn-lowercase Convert selection to lowercase -
capitalize .btn-capitalize Capitalize selection words -
removeformatting .btn-removeformatting Remove all markdown formatting styles -
gotoline .btn-gotoline Navigate to specific line Ctrl+G (Cmd+G on Mac)
link .btn-link Insert link using link modal dialog Ctrl+K (Cmd+K on Mac)
image .btn-image Insert image via URL or file upload modal -
video .btn-video Insert video shortcode via modal -
audio .btn-audio Insert audio shortcode via modal -
component .btn-component Insert [component] shortcode block via modal -
figure .btn-figure Insert [figure] shortcode block via modal -
help .btn-help Open keyboard shortcuts help modal Ctrl+/ (Cmd+/ on Mac)

Hiding Buttons via CSS

Although the preferred way to hide buttons is through the toolbar array configuration, host platforms can also hide toolbar buttons by editing or overriding styles in packages/core/assets/toolbars/toolbar-default.css. E.g., to hide the "Redo" and "Heading" buttons:

.toolbar-btn.btn-redo,
.toolbar-btn.btn-heading {
  display: none;
}

Adding a New Toolbar Button

To add a brand-new formatting tool to Traven, follow these three steps:

  1. Register the Tool: Define the tool metadata and action inside src/toolbar/tools.js under TOOL_REGISTRY:
    strikethrough: {
      key: "strikethrough",
      title: "Strikethrough",
      shortcut: "Ctrl+Shift+S",
      keybinding: "Mod-Shift-s",
      icon: `<svg>...</svg>`,
      action: (editor) => editor.insertSnippet("~~", "~~", "strikethrough")
    }
    
  2. Add to Toolbar Config: Add the new tool key (e.g., "strikethrough") to DEFAULT_TOOLBAR in src/index.js, or include it in the custom toolbar array passed during initialization.
  3. Optional Custom Styling: Add specific visual rules for the button class (e.g., .btn-strikethrough) in packages/core/assets/toolbars/toolbar-default.css if custom styling is needed.

5. Runtime APIs, Extensions & Integration Hooks

Recent core enhancements introduced runtime-level configurations, real-time analytics, custom markdown compilers, and Vim emulation.

A. Editor Dark Mode Theme

Traven supports a separate dark mode state for CodeMirror editor scopes, decoupled from the surrounding page context:

  • Activation: Set the initialization option theme: "dark" or call setTheme("dark") dynamically at runtime.
  • Mechanism: Dynamic theme toggling works by adding/removing the .cm-wysiwym-dark class from the editor host DOM nodes (this.#view.dom and this.#rawView.dom).
  • Styling: Specific dark styles (such as caret, gutters, code blocks, and markdown token colors) are separated into src/style.css. External visual skin styles are placed in scoped .cm-wysiwym-dark selectors in the respective skin files (e.g. skin-light.css, skin-colorful.css).

B. Real-Time Statistics API

Host applications can query document size statistics at runtime or subscribe to changes:

  • Getter Methods: getCharacterCount(), getWordCount(), and getReadTime() (calculates reading time dynamically using a standard 200 words-per-minute threshold).
  • Event Hook: Bind change listeners via on("statsUpdate", (stats) => { ... }).
  • Important Timing Caveat: When the editor is initialized, it fires the initial stats count. To guarantee that the host application has time to register its event listener, this initial trigger is deferred using a microtask:
    Promise.resolve().then(() => {
      this.#triggerStatsUpdate();
    });
    

C. Dynamic Markdown Renderer Hook

Traven features a compilation hook allowing custom renderers to generate the HTML preview:

  • Registration: Call registerRenderer(renderFn) to pass a custom Markdown-to-HTML parser function (e.g., marked, markdown-it, micromark).
  • Usage: getContentHtml() retrieves the rendered HTML of the current document.
  • Out-of-the-Box Fallback: If no renderer is registered, Traven falls back to #fallbackRender(md), a secure, parser-assisted inline renderer that strips frontmatter metadata (via Lezer syntax tree boundaries) and parses headings, blockquotes, code, lists, linebreaks, images (![alt](url)), links ([link="url"]text[/link]), and inline text styling safely.
  • Preview Styling & Skin Sync: The preview container element uses the .traven-preview class. Styles for headings, lists, links, blockquotes, and code elements inside .traven-preview are defined directly in the theme skins (e.g. skin-light.css, skin-colorful.css, skin-dark.css) and dark mode class triggers (.cm-wysiwym-dark). This ensures the Preview tab mirrors the exact visual typography, colors, and borders of the current active skin and dark/light configuration dynamically.

D. Vim Keybindings Support

Vim-mode keybindings can be toggled on-the-fly inside both the WYSIWYM and raw Markdown editor panes:

  • Activation: Toggle via the initialization option vimMode: true or by calling the dynamic setVimMode(enabled) API.
  • Dynamic Reconfiguration: The keybindings are managed through a CodeMirror Compartment called vimCompartment. This allows swap-reloading the Vim keymap extensions (import { vim } from "@replit/codemirror-vim") at runtime without rebuilding the editor view states.
  • Fat Cursor Styling: The block cursor in Vim Normal Mode (.cm-fat-cursor) is configured with opacity: 0.6 !important globally in src/style.css and in all theme skins. This ensures the characters covered by the block cursor are readable across all light and dark visual themes.

E. Editor Lifecycle & State Management API

Traven exposes a complete set of methods for programmatic editor control and SPA integration:

  • setReadOnly(boolean): Toggles the editor between read-only and read-write mode using the readOnlyCompartment.reconfigure() pattern. Applies to both the primary WYSIWYM view and the raw source view if present.
  • isReadOnly(): Returns the current read-only state (true/false).
  • focus(): Programmatically focuses the primary editor via this.#view.focus().
  • getSelection(): Returns the currently selected text from state.selection.main.
  • setSelection(anchor, head): Sets the cursor position or selection range and focuses the editor. If head is omitted, places a caret at anchor.
  • getView(): Returns the primary CodeMirror EditorView instance for advanced use cases.
  • triggerSave(): Programmatically fires the "save" event with the current document value, without requiring a Cmd/Ctrl+S keypress.
  • destroy(): Destroys both the primary and raw EditorView instances and clears all registered event listeners. Essential for SPA integrations that mount/unmount editors dynamically to prevent memory leaks.

F. Link WYSIWYM Rendering

Traven renders markdown links ([link="url"]text[/link]) and autolinks (<url>) with inline WYSIWYM collapsing:

  • Mechanism: The Link node's child tree (produced by Lezer's markdown parser) is walked to identify LinkMark, URL, and LinkTitle children. All syntax characters ([, ], (, ), the URL, and the title) are collapsed with Decoration.replace({}), leaving only the visible link text styled with .cm-wysiwym-link-anchor.
  • Autolinks: The Autolink node's angle brackets (<, >) are similarly collapsed, with the URL text styled as a link anchor.
  • Cursor-inside reveal: When the cursor is within a link's range (cursorHead >= node.from && cursorHead <= node.to), all syntax is revealed for editing. This follows the same pattern used for bold, italic, and all other inline decorations.
  • Title tooltips: If a link has a title attribute ([link="url "title""]text[/link]), the title is extracted from the LinkTitle node, stripped of surrounding quotes, and set as a native title attribute on the decoration's <span> element. This provides a browser-native hover tooltip.
  • Ctrl/Cmd+Click: A domEventHandlers extension intercepts clicks with ctrlKey or metaKey held. If the click position falls within a Link or Autolink node, the URL is extracted and opened in a new tab via window.open(url, "_blank", "noopener,noreferrer").
  • Delimiter skip: Arrow key navigation across collapsed link boundaries is handled by delimiter-skip.js. Left/right arrow keys skip over the [link="url"], [/link] collapsed regions so the cursor jumps directly between the link text and the surrounding content.

G. Image Insertion Modal

Traven provides a manual image insertion path complementing the existing optimistic drag/drop/paste uploader (src/images.js):

  • Modal trigger: The image toolbar button (.btn-image) opens openImageModal() from src/toolbar/modal.js.
  • Two insertion paths:
    • Direct URL: Enter an image URL and optional alt text. Inserts ![alt](url) at the cursor.
    • File Upload: If the host application configured onUploadImage(file), the modal shows a file picker. Selecting a file disables the URL field (mutual exclusivity). The Insert button changes to "Uploading…" while the upload promise resolves, then inserts ![alt](finalUrl).
  • Upload handler access: The modal retrieves the upload callback via editor.getUploadHandler(), a public getter that returns the onUploadImage function or null.
  • Error handling: If the upload promise rejects, the modal shows a red error message and re-enables the Insert button, allowing the user to retry or switch to URL input.
  • Selection pre-fill: If text is selected when the modal opens, the alt text field is pre-filled with the selection content (mirrors the link modal behavior).
  • Drag suppression: The ImageWidget in images.js sets draggable="false" and intercepts mousedown to prevent native browser drag ghosts from disrupting CodeMirror text selection.

H. Interactive Table Editor & WYSIWYM Widget

Traven includes a graphical Table Editor and full WYSIWYM rendering for GFM tables:

  • Interactive Table Widget (TableWidget):
    • Resides in src/wysiwym.js.
    • Parses raw table blocks when the cursor is outside using the shared helper parseMarkdownTable.
    • Replaces raw Markdown with a clean, theme-styled HTML <table> DOM element.
  • Table Editor Modal (openTableModal):
    • Resides in src/toolbar/modal.js and is activated either by clicking a rendered TableWidget or via the table toolbar tool (.btn-table).
    • Provides a spreadsheet-like GUI editor containing a contenteditable grid.
    • Features toolbar actions to Add Column, Delete Column, Add Row, and Delete Row, rendered using customized Phosphor SVG icon definitions.
    • Implements keyboard focus trapping and custom Tab / Shift+Tab / Enter navigation to move seamlessly between grid cells and modal buttons.
  • GFM Table Parsing & Serialization:
    • Parsing: parseMarkdownTable(tableText) uses a character-by-character scanner to correctly parse header columns, cell contents, and column alignments (left, center, right). It handles escaped pipes (\| -> |) inside cell data and defaults to "left" alignment if not specified.
    • Serialization: serializeTableToMarkdown(headers, rows, alignments) reconstructs the table string. It escapes raw pipes in cells (| -> \|), uses explicit separator column colons (:---), and aligns column paddings dynamically to output clean, reader-friendly Markdown.
  • WYSIWYM Cell Formatting & Alignments:
    • Inline formatting: Cells run through a renderInlineMarkdown helper converting bold (**, __), italics (*, _), inline code (`), links ([link="url"]text[/link]), and images (![alt](url)) into rendered markup.
    • Theme highlight sync: Highlight tokens (==) are converted into <span class="cm-wysiwym-highlight"> so they inherit visual highlights directly from the active editor stylesheet/skin instead of defaulting to generic browser-native yellow <mark> styling.
    • Alignment styling: Headers and cells dynamically apply style.textAlign matching the parsed column alignment settings.
    • Interactive Links: Clicks on link anchors (<a>) in the WYSIWYM cells bypass the modal launch, letting the browser follow the link in a new tab.
    • Min cell dimensions: Both the editor widget cells and the HTML Preview cells apply height: 38px and box-sizing: border-box across all skins (skin-light.css, skin-colorful.css, skin-dark.css) to prevent blank cells from visually collapsing.

I. Lezer-based YAML Frontmatter Parsing

To prevent common regex matching bugs on mixed CRLF/LF line endings, nested --- tokens, or leading horizontal rules, Traven relies on Lezer's syntax tree to parse and strip YAML frontmatter:

  • Canonical Node Check: The editor maps frontmatter to the "Frontmatter" syntax node emitted by @codemirror/lang-yaml (yamlFrontmatter).
  • Validation Guard: In #fallbackRender, frontmatter is stripped only if the node starts at index 0 and contains at least two "DashLine" tokens. This ensures a single --- at the beginning of the file is correctly rendered as a horizontal rule rather than treated as a truncated frontmatter block.

J. Unified Lezer-based List Parsing & Toggling

To prevent subtle divergence bugs between the WYSIWYM decorations builder (src/wysiwym.js) and editor commands (insertList, removeFormatting in src/index.js), list matching is unified via Lezer syntax tree analysis:

  • getListPrefixAt(state, pos): Checks if the line contains a list item (ListItem) starting on that line. It performs a pre-order traversal starting at the first non-whitespace character after blockquote prefixes to locate the ListMark and TaskMarker nodes. Returns { type: 'ol' | 'ul' | 'task', from: number, prefixLen: number, taskMarker }.
  • getListStrippingRanges(state, from, to): Resolves precise list stripping offsets for selections. It handles list indentations correctly, but preserves blockquote prefixes (>) at the start of the line.
  • isInCodeBlock(state, pos): Prevents editor commands from inserting list formatting when the selection starts inside a fenced code block or inline code text.
  • Benefits: Resolves pre-existing bugs such as incorrect parsing of star/plus list bullets (*, +), double-insertions on negative numbers (-3.14), and formatting corruptions inside code blocks.

K. Custom Image Shortcode Parser & Widget

Traven includes support for an optional, self-closing [image src="..." align="..." size="..." caption="..." class="..." alt="..."] shortcode system:

  • Optional & Backwards-Compatible: This custom shortcode is completely optional. Traven remains fully backwards-compatible with standard Markdown image syntax (![alt](src)). Legacies images will parse, render, and compile exactly as they did previously.
  • Lezer Custom Inline Parser (src/shortcode-parser.js): Implements a custom @lezer/markdown inline parser that detects the [image tag, scans for key-value attribute pairs (normalizing single, double, or unquoted values), and builds a structured AST subtree with nodes like ImageShortcode, ShortcodeMark, ShortcodeTagName, ShortcodeAttributeName, and ShortcodeAttributeValue. It is integrated into the CodeMirror markdown configuration inside src/index.js.
  • WYSIWYM Widget Rendering: The ImageShortcodeWidget in src/wysiwym.js collapses the shortcode text block into a styled block preview widget showing the image thumbnail/element with custom sizing, alignment styling (via auto-margins to preserve CodeMirror coordinate mapping), and badges, while hiding the raw code when the cursor is outside.
  • Fallback HTML Compilation: The #fallbackRender method compiles [image] shortcodes into high-quality semantic <img> elements with mapped attributes and class names (such as .traven-image-shortcode, .align-[alignment], and .size-[size]). In order to maintain a separation of concerns, the renderer outputs zero inline styles, delegating layout, width, float, and margin styling entirely to the skin stylesheets.
  • Toolbar Toggle: The "Insert Image" toolbar modal features a sliders-icon toggle to dynamically switch between Advanced mode (inserting custom [image] shortcodes with fields for alt text, captions, class names, alignments, and sizes) and Legacy mode (inserting standard ![alt](src) Markdown).
  • Delimiter Skip Integration: Delimiter skip logic in src/delimiter-skip.js detects ImageShortcode syntax boundaries and allows arrow keys to skip across the delimiters (jump to first attribute when entering, skip closing brackets when exiting).

L. Custom [component] Shortcode & Alias System (Blockquotes, Pullquotes, Generic Cards)

Traven supports a flexible, block-level custom component shortcode system that wraps nested text, supporting attributes and aliases:

  • Lezer Parser Extension (src/component-parser.js): Implements a paired-tag inline scanner that identifies opening [component] and closing [/component] syntax boundaries. It parses key-value attributes (e.g. name, author, source) into a structured AST subtree containing ComponentShortcode, ComponentShortcodeOpen, ComponentShortcodeClose, ComponentShortcodeBody, ComponentShortcodeTagName, and attribute name/value tokens.
  • Shorthand Aliases & Syntax Normalization: To optimize writer workflows, the parser and editor natively translate short-syntax attributes and custom tag aliases:
    • Short Attribute: [component="blockquote"] is normalized to [component name="blockquote"].
    • Shorthand Quote Aliases: [quote author="..." source="..."]...[/quote] and [blockquote author="..." source="..."]...[/blockquote] map to blockquote under the hood.
    • Shorthand Pullquote Alias: [pullquote]...[/pullquote] maps to pullquote under the hood.
  • WYSIWYM Widget Rendering: The ComponentShortcodeWidget in src/wysiwym.js collapses the raw tags when the cursor is outside, rendering a styled preview panel. It features a mouse hover edit-trigger overlay. Clicking the component or the overlay button launches the interactive Component Modal dialog to modify attributes or body content.
  • Twig-Compatible Fallback Compilation: In #fallbackRender, components are parsed and converted to high-quality semantic HTML/Twig containers with no inline styles:
    • Blockquotes: Renders as <blockquote class="traven-component-blockquote">[inner HTML]<footer><cite>— Author, Source</cite></footer></blockquote> if author or source is defined; otherwise, just the <blockquote>.
    • Pullquotes: Renders as <blockquote class="traven-component-pullquote">[inner HTML]</blockquote>.
    • Generic/Fallback Cards: Any generic or unknown component names (like [component="info"] or [component="warning"]) fall back to card layouts: <div class="traven-component traven-component-[name]">[inner HTML]</div>, allowing theme developers to target them with stylesheet rules.
  • Delimiter Skip Integration: Delimiter skipping in src/delimiter-skip.js allows arrow keys to skip over opening/closing delimiters and jump inside the block content smoothly.

M. Custom Video Shortcode [video ...] Parser & Widget

Traven includes native support for an optional [video src="..." align="..." size="..." caption="..." class="..."] shortcode system:

  • Lezer Custom Inline Parser (src/video-parser.js): Implements a custom @lezer/markdown inline parser that detects the [video tag, parses key-value attributes, and builds a structured AST subtree containing VideoShortcode, VideoShortcodeMark, VideoShortcodeTagName, and attribute name/value tokens. It is integrated into the CodeMirror markdown parser inside src/index.js.
  • WYSIWYM Widget Rendering: The VideoShortcodeWidget in src/wysiwym.js collapses the raw shortcode text when the cursor is outside. It renders a clean placeholder card indicating the video's detected platform type (YouTube, Vimeo, or Video File) and URL, alongside an edit icon. Clicking the widget or edit icon launches the interactive Video Modal to modify attributes.
  • Fallback HTML Compilation: The #fallbackRender method compiles [video] shortcodes into high-quality semantic HTML structures with zero inline styles:
    • YouTube: <iframe src="https://www.youtube.com/embed/[id]" ...></iframe>
    • Vimeo: <iframe src="https://player.vimeo.com/video/[id]" ...></iframe>
    • Direct/Local Video Files: <video src="[url]" controls></video>
    • Containers: Wrapped in <figure class="traven-video-figure align-[align] size-[size][custom-class]"> containing a <figcaption class="traven-video-caption"> if a caption is present; otherwise, wrapped in a <div class="traven-video-container align-[align] size-[size][custom-class]">.
  • Toolbar Button: The video toolbar tool (.btn-video) opens openVideoModal() from src/toolbar/modal-video.js allowing users to insert or edit video shortcodes with explicit alignment, size, class, and caption fields.
  • Delimiter Skip Integration: Delimiter skipping in src/delimiter-skip.js automatically detects VideoShortcode syntax boundaries, allowing arrow keys to jump inside/across the delimiters smoothly.

N. Custom Audio Shortcode [audio ...] Parser & Widget

Traven includes native support for an optional [audio src="..." align="..." size="..." caption="..." class="..."] shortcode system:

  • Lezer Custom Inline Parser (src/audio-parser.js): Implements a custom @lezer/markdown inline parser that detects the [audio tag, parses attributes, and builds a structured AST subtree containing AudioShortcode, AudioShortcodeMark, AudioShortcodeTagName, and attribute name/value tokens.
  • WYSIWYM Widget Rendering: The AudioShortcodeWidget in src/wysiwym.js collapses the raw shortcode text when the cursor is outside. It renders a clean placeholder card displaying the audio icon, the source URL/file, and the caption, with a click-to-edit option. Clicking the widget launches the interactive Audio Modal.
  • Fallback HTML Compilation: The #fallbackRender method compiles [audio] shortcodes into <audio controls> elements with zero inline styles:
    • Containers: Wrapped in <figure class="traven-audio-figure align-[align] size-[size][custom-class]"> containing a <figcaption class="traven-audio-caption"> if a caption is present; otherwise, wrapped in a <div class="traven-audio-container align-[align] size-[size][custom-class]">.
  • Toolbar Button: The audio toolbar tool (.btn-audio) opens openAudioModal() from src/toolbar/modal-audio.js to insert or edit audio shortcodes.
  • Delimiter Skip Integration: Delimiter skipping in src/delimiter-skip.js automatically detects AudioShortcode syntax boundaries for seamless arrow navigation.

O. Custom Figure Shortcode
Parser & Widget

Traven includes native support for an optional [figure align="..." size="..." caption="..." class="..."]...[/figure] shortcode system designed to wrap block-level contents in a captioned figure block:

  • Lezer Custom Inline Parser (src/figure-parser.js): Implements a custom @lezer/markdown inline parser that detects the [figure tag, parses attributes, and builds a structured AST subtree containing FigureShortcode, FigureShortcodeOpen, FigureShortcodeClose, FigureShortcodeBody, FigureShortcodeTagName, and attribute name/value tokens.
  • WYSIWYM Widget Rendering: The FigureShortcodeWidget in src/wysiwym.js collapses the raw shortcode text when the cursor is outside. It renders the figure's body content (allowing nested block elements like images, code blocks, or tables) and displays a caption underneath if specified, alongside an edit icon. Clicking the widget launches the interactive Figure Modal.
  • Fallback HTML Compilation: The #fallbackRender method compiles [figure]...[/figure] shortcodes into a standard <figure> container with zero inline styles:
    • <figure class="traven-figure align-[align] size-[size][custom-class]">[inner HTML]<figcaption class="traven-figure-caption">[caption]</figcaption></figure>
  • Toolbar Button: The figure toolbar tool (.btn-figure) opens openFigureModal() from src/toolbar/modal-figure.js to insert or edit figure shortcodes.
  • Delimiter Skip Integration: Delimiter skipping in src/delimiter-skip.js automatically detects FigureShortcode syntax boundaries for seamless arrow navigation.

6. CSS Theming & WYSIWYM Mode Parity Tips

When converting or adapting a normal CSS stylesheet from a standard HTML website to work inside Traven's WYSIWYM Mode, developers must be aware of several critical CodeMirror 6 rendering behaviors to avoid vertical alignment and spacing discrepancies between WYSIWYM (Editor) and Preview modes:

A. Heading Padding and General Line Overrides

  • Problem: CodeMirror 6 wraps every text line inside a div.cm-line. If a skin applies a general stylesheet rule to strip line padding (e.g., .cm-editor .cm-line { padding: 0 !important; }), this general rule will take precedence over specific class rules for headings (like .cm-editor .cm-wysiwym-h1 { padding-top: 27px; }). This results in heading lines having 0px of padding-top/bottom inside the editor, while their HTML Preview equivalents (which use standard margin-top) render with the correct spacing, creating a vertical alignment mismatch.
  • Fix: Always specify !important on the padding declarations of editor headings to guarantee they override any general line formatting rules:
    .cm-editor .cm-wysiwym-h1 {
      padding-top: 27px !important;
      padding-bottom: 0 !important;
    }
    

B. First-Child Margin Collapse in Preview

  • Problem: When mounting the HTML Preview container (e.g., #html-preview / .traven-preview), the first child element is typically a heading (<h1>). If the preview container has padding-top: 0 and no border, the margin-top of this first-child heading will collapse with the container, pushing the entire preview block down. In WYSIWYM mode, however, the editor's first line starts at the top, and any space above it comes from the line's padding-top (which doesn't collapse).
  • Fix: Explicitly match the top margins of the first-child headings in Preview Mode with the top paddings of the editor:
    .traven-preview > h1:first-child { margin-top: 27px !important; }
    .traven-preview > h2:first-child { margin-top: 12px !important; }
    .traven-preview > h3:first-child { margin-top: 4.5px !important; }
    

C. Collapsing Separator Blank Lines (Post-Heading Gaps)

  • Problem: In markdown documents, a blank line separating headings and paragraphs is standard practice. While the HTML Preview parser collapses this blank line into structural block positioning (e.g., placing the paragraph directly under the heading), CodeMirror still renders this blank line as a physical div.cm-line with full vertical height (e.g., 33px), causing an excessively large gap under editor headings.
  • Fix: Target blank lines (.cm-line:has(br:only-child)) that immediately follow heading blocks in the editor, and reduce their height to a smaller proportional value (e.g., 0.6em or 12px):
    .cm-editor .cm-wysiwym-h1 + .cm-line:has(br:only-child),
    .cm-editor .cm-wysiwym-h2 + .cm-line:has(br:only-child),
    .cm-editor .cm-wysiwym-h3 + .cm-line:has(br:only-child) {
      height: 0.6em !important;
      min-height: 0.6em !important;
    }
    

D. Fenced Code Block Spacing & Collapsed Fences

  • Problem: When a fenced code block is collapsed in WYSIWYM mode (when the cursor is outside), CodeMirror collapses the text of the opening/closing fence lines but still renders their empty container divs (div.cm-line). These empty lines will take up full vertical space. Combined with actual blank lines before and after the block, this creates a massive visual gap before and after code blocks in the editor.
  • Fix:
    1. Add a class decoration (such as cm-wysiwym-collapsed-fence) to the opening and closing fence lines when they are collapsed.
    2. Set their visual and physical height to 0 in CSS to remove them from the vertical layout flow safely:
      .cm-editor .cm-wysiwym-collapsed-fence {
        height: 0 !important;
        min-height: 0 !important;
        padding: 0 !important;
        margin: 0 !important;
        border: none !important;
        overflow: hidden !important;
        visibility: hidden !important;
      }
      
    3. Ensure the Preview Mode preformatted block (.traven-preview pre) has matching margins (margin-top: 1.5em; margin-bottom: 1.5em;) to maintain proportional layout parity.

E. Editor Block Widgets and Floats (Coordinate Mapping Errors)

  • Problem: Block replacement decorations (Decoration.replace({ widget: ..., block: true })) like the image shortcode widget will completely throw off cursor positioning for any content below them if CSS float: left or float: right or vertical margin is applied in the editor.
    • Floats remove elements from the normal document flow. CodeMirror's coordinate mapping (posAtCoords) expects all blocks to be stacked sequentially. A floated element makes text wrap beside it, which confuses CodeMirror, causing mouse clicks below/beside it to map to the wrong character offsets (typically landing on lines earlier in the document).
    • Vertical margins are not measured by CodeMirror's layout manager and create vertical displacement errors.
  • Fix: Never use float or vertical margin on block widgets in the editor skins.
    • For horizontal alignment, use auto-margins (e.g. margin: 0 auto 0 0 !important to align left, margin: 0 0 0 auto !important to align right, and margin: 0 auto !important to center).
    • For vertical spacing, use top/bottom padding on the widget container instead of margin.
    • Floats are still fully supported and recommended in the HTML Preview (.traven-preview) stylesheet definitions where CodeMirror coordinate calculations do not apply.

F. Font Family Inheritance on Nested Paragraphs (The CSS Cascade Override)

  • Problem: skin-starter.css (which is bundled inside dist/traven.css) defines a high-priority .traven-preview p { font-family: var(--traven-font-body) !important; } rule. Because standard blockquotes, blockquote components ([component="blockquote"]), and info/warning notice components ([info], [warning]) contain nested paragraph (p) elements, the nested p tags will inherit/use the starter skin's default body typeface (Georgia or var(--traven-font-body)), completely overriding any custom font-family declared on the parent container elements (such as skin-editorial's 'Goudy Bookletter 1911', skin-modern's 'Epunda Slab', or other custom skin stacks).
  • Fix: When styling custom components, blockquotes, or any notice cards that contain paragraphs in both the editor and preview DOM scopes, target the container element and all its descendants (using the universal selector *) to apply the skin's custom typography with !important:
    .cm-wysiwym-component-shortcode.component-info,
    .cm-wysiwym-component-shortcode.component-info * {
      font-family: 'Atkinson Hyperlegible Next', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
    }
    

For the complete guide to runtime font customization using CSS custom properties — including dynamic Google Fonts loading, static overrides, and extending font roles beyond the default three — see Custom Typography.


7. Security & Input Sanitization

To protect host applications from Cross-Site Scripting (XSS) when rendering user-submitted Markdown as HTML, Traven includes an input sanitization pipeline.

A. Fallback HTML Escaping

Traven's fallback Markdown-to-HTML parser (#fallbackRender in packages/core/src/TravenEditor.js) automatically encodes all raw HTML tags (such as <script>, <iframe, etc.) into escaped characters (&lt;, &gt;, &amp;) before parsing block/inline elements. This ensures that any direct HTML injection renders visually as plain text in the browser.

B. Reusable URL Sanitizer (packages/core/src/security.js)

To protect against URI-based XSS attacks—specifically through Markdown links [link="javascript:..."]click[/link] or images ![alt](javascript:...)—Traven isolates its URL sanitization logic inside packages/core/src/security.js.

  • Mechanism: The exported sanitizeUrl(url) function normalizes input URLs by resolving HTML entity encodings (e.g. j&#97;vascript:) and percent encodings (e.g. java%0ascript:). It blocks dangerous URI schemes like javascript:, data:, and vbscript:, replacing them with about:blank.
  • Allowed Schemes: The function explicitly allows safe protocols (http:, https:, mailto:, tel:), relative paths (e.g., /about), hashtag anchors (e.g., #id), and raw blog slugs (e.g., my-slug-name), ensuring users have complete freedom for local and internal routing.
  • Reusability: Future features that parse or output links (such as the planned custom [link] / [/link] shortcode) should import and wrap URLs using sanitizeUrl:
    import { sanitizeUrl } from "./security.js";
    
    const safeHref = sanitizeUrl(userProvidedUrl);
    

8. LaTeX Math Support & Privacy-First Rendering

Traven features native LaTeX math rendering via custom parsing, dynamic editor widgets, and a fallback preview compilation pipeline:

A. Custom Lezer Parser Extensions (src/math-parser.js)

  • Grammar definition: An inline Lezer parser extension parses inline math (delimited by $) and display math blocks (delimited by $$). It outputs InlineMath, BlockMath, and MathMark syntax nodes.
  • Escaped delimiters: Checks and skips escaped dollar signs (\$) to prevent false-positive delimiter matches in document bodies.

B. WYSIWYM Math Widget (MathWidget)

  • Widget Lifecycle: Registered in src/wysiwym.js. The math widget uses KaTeX to render equations when the cursor is outside the math block range, and collapses raw markup delimiters.
  • Cursor Focus Toggle: When the cursor enters the node range, the widget is replaced with raw LaTeX markup to let authors edit equations in plain text.

C. Fallback HTML Preview Compilation

  • Pipeline Extraction: To prevent standard Markdown rules (like bold, italic, or links) from corrupting complex LaTeX symbols inside equation blocks, math blocks are extracted early in #fallbackRender and replaced with safe, unique placeholder tokens.
  • Post-processing Render: The placeholder tokens are replaced back during the final rendering pass. They are compiled into HTML using KaTeX (if available) or styled local fallback HTML tags if KaTeX has not loaded.

D. Privacy-First Asset Loading

  • Opt-in Only: Dynamically loading KaTeX script and stylesheet tags is strictly opt-in and disabled by default.
  • Constructor Option:
    • katex: false (Default): Checks for a pre-loaded global window.katex instance. If absent, falls back to local CSS-styled plain-text equations.
    • katex: true: Dynamically injects script and style tags to load the latest KaTeX package from a public CDN.
    • katex: { js: "...", css: "..." } or katex: "...": Dynamically loads assets from local, self-hosted paths to keep the system fully offline and telemetry-free.

9. Floating & Hybrid Toolbar Architecture

Traven supports three toolbar layout modes: "static" (traditional fixed toolbar), "floating" (clean canvas with contextual menus), and "hybrid" (fixed toolbar with inline format bubble and gutter inserter helper menus).

A. Viewport-Based Fallback

To ensure accessibility on mobile, the layout mode is resolved dynamically:

  • isTouchPhone(): Scans viewports using CSS media query listeners (pointer: coarse, hover: none, and width <= 768px) to identify mobile touch devices.
  • Enforced Static Mode: On touch-based mobile viewports, the editor automatically forces toolbarMode: "static", ensuring user access to all formatting options without relying on precise mouse cursor selections.

B. Dynamic Stylesheet Injection

  • loadStyles(): To eliminate manual <link> management on host pages, src/toolbar/load-styles.js dynamically scans the DOM's stylesheets. If .traven-slim-rail is not already styled (meaning dist/traven.css has not been loaded), it appends a <link> pointing to the local packages/core/assets/toolbars/toolbar-floating.css.

C. Slim Control Rail & Roving Tabindex

  • Rail Element: In "floating" mode, the static toolbar is replaced by a horizontal rail containing global document-level actions (Undo, Redo, Save, Vim, Help, Clear, and Fullscreen) and the statistics widget.
  • Roving Tabindex: Keyboard navigation in the rail follows the WAI-ARIA pattern: arrow keys navigate horizontally between buttons, updating the tabindex dynamically so pressing Tab returns the user back to the editor canvas smoothly.

D. Selection Bubble Formatting

  • Format Bubble Menu: Mounts formatting options directly near the cursor using CodeMirror 6 showTooltip.
  • Appearance Debounce: The bubble does not appear instantly on selection to avoid flickering during selection drags. It is debounced by a configurable delay (bubbleAppearDelay options parameter, defaulting to 200ms) after the pointer stops moving.
  • Keyboard Trapping: Pressing Escape while formatting dismisses the bubble and redirects focus directly back to the editor view.
  • Tablet Override: On touch tablets, the bubble converts into a sticky bottom action bar, preventing soft-keyboard overlaps.

E. Gutter Insertion Menu

  • Contextual Inserter: Hovering or positioning the cursor on an empty line reveals a gutter + button, opening a block-insertion popup.
  • Hotkey Trigger: Pressing Mod-Shift-Enter triggers the insert menu at the current cursor line.

F. Selection-to-Block Insertion Shortcut

  • Mechanism: The Selection Bubble features an insert shortcut button (.btn-bubble-insert, showing a pencil-ruler SVG) to insert block elements immediately after the selected text's line.
  • Layout Spacing & Newline Normalization: To prevent block elements (e.g., tables, code blocks, figures) from colliding with preceding or succeeding paragraphs, the action dynamically inspects the characters succeeding the current line. It calculates and inserts the exact number of newlines needed to guarantee a 4-newline gap between the current line and subsequent content (or 3 newlines if at the end of the document).
  • Cursor Positioning: The cursor is positioned at anchorLine.to + 2 within this newly created spacing area, leaving exactly one blank line above and one blank line below. The Gutter Insertion Menu is then triggered at this cursor position on the next tick.