Skip to content
Brok UI

Loading…

No results

Markdown

Open source

Renders untrusted Markdown to safe HTML on the server (CommonMark plus GitHub tables, strikethrough, task lists and autolinks) with raw HTML escaped and unsafe links removed, styled by the rich prose variant.

Version
v1.1.0
Stability
stable
License
MIT
Related
Prose
Editor
Code Block

Preview

Release notes

Markdown from users renders safely on the server, with emphasis, removed text and inline code. Read the install guide or visit https://example.com.

What changed

  • Lists keep their markers
    • and nest a second level
      • and a third
  • Done: Task lists show their state
  • Not done: Open tasks stay unchecked
  1. Ordered steps count up
  2. Raw HTML such as <script>alert(1)</script> is shown as text

Quotes sit on a start-side rule that mirrors in right-to-left text.

$html = Str::markdown($comment->body, ['html_input' => 'escape']);

Element Behaviour
Code block Scrolls inside itself
Table Scrolls inside its wrapper

The Brok social preview image

previews.components.markdown.default.blade.php Blade
@php
    $source = <<<'MARKDOWN'
    # Release notes

    Markdown from users renders **safely** on the server, with _emphasis_, ~~removed text~~ and `inline code`. Read the [install guide](https://example.com/docs/markdown) or visit https://example.com.

    ## What changed

    - Lists keep their markers
      - and nest a second level
        - and a third
    - [x] Task lists show their state
    - [ ] Open tasks stay unchecked

    1. Ordered steps count up
    2. Raw HTML such as <script>alert(1)</script> is shown as text

    > Quotes sit on a start-side rule that mirrors in right-to-left text.

    ```php
    $html = Str::markdown($comment->body, ['html_input' => 'escape']);
    ```

    ---

    | Element | Behaviour |
    | --- | ---: |
    | Code block | Scrolls inside itself |
    | Table | Scrolls inside its wrapper |

    ![The Brok social preview image](/og-default.png)
    MARKDOWN;
@endphp

<x-ui.markdown :source="$source" external-links="nofollow" />

Installation

terminal
php artisan ui:add markdown

Registry contract

php artisan ui:add markdown writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/markdown.blade.php
Registry dependencies
prose tabs copy-button
Packages
composer: jml/brok:^0.2

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

markdown.md
# Brok UI: Markdown (`markdown`)

Renders untrusted Markdown to safe HTML on the server (CommonMark plus GitHub tables, strikethrough, task lists and autolinks) with raw HTML escaped and unsafe links removed, styled by the rich prose variant.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add markdown
```

## Usage

```blade
@php
    $source = <<<'MARKDOWN'
    # Release notes

    Markdown from users renders **safely** on the server, with _emphasis_, ~~removed text~~ and `inline code`. Read the [install guide](https://example.com/docs/markdown) or visit https://example.com.

    ## What changed

    - Lists keep their markers
      - and nest a second level
        - and a third
    - [x] Task lists show their state
    - [ ] Open tasks stay unchecked

    1. Ordered steps count up
    2. Raw HTML such as <script>alert(1)</script> is shown as text

    > Quotes sit on a start-side rule that mirrors in right-to-left text.

    ```php
    $html = Str::markdown($comment->body, ['html_input' => 'escape']);
    ```

    ---

    | Element | Behaviour |
    | --- | ---: |
    | Code block | Scrolls inside itself |
    | Table | Scrolls inside its wrapper |

    ![The Brok social preview image](/og-default.png)
    MARKDOWN;
@endphp

<x-ui.markdown :source="$source" external-links="nofollow" />
```

## Props

- `source` (string, default ``) — The Markdown text to render. Pass untrusted text here; raw HTML in it is escaped and unsafe link schemes are removed. A non-string value renders nothing.
- `size` (md|sm|lg, default `md`) — Forwarded to prose: reading measure and base text size.
- `as` (string, default `div`) — Forwarded to prose: the tag for the text block (article, div, section or main). Defaults to div because Markdown usually sits inside a card or a column.
- `centered` (bool, default `false`) — Forwarded to prose: centers the bounded-width block. Off by default so the text aligns to the start edge of its container.
- `externalLinks` (none|nofollow|new-tab, default `none`) — Set as external-links. none leaves links as written; nofollow adds rel="nofollow noopener noreferrer" to links whose host differs from the app URL or the current request host; new-tab also adds target="_blank". Relative links are always internal.
- `headingOffset` (int, default `0`) — Set as heading-offset. Moves every heading down this many levels (0 to 5, capped at h6), so # renders as h2 with an offset of 1 when the page already has an h1.
- `sourceToggle` (bool, default `false`) — Set as source-toggle. Adds a Formatted / Source tab pair and a Copy Markdown button above the text; Source shows the raw Markdown escaped, in monospace, wrapped. Off by default, which renders exactly as before.

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Displaying Markdown written by users or stored in the database, such as comments, notes, descriptions or imported documents, as formatted read-only text.
- Needing a server-only renderer that escapes raw HTML and drops javascript:, vbscript: and data: links without adding JavaScript or a composer package.

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- Users need to write or edit formatted text; use editor, which is an input surface.
- The content is already trusted HTML (for example saved editor output); wrap it in prose with variant="rich" instead of converting it again.
- The content needs raw HTML embeds, iframes or custom components; this renderer escapes all raw HTML on purpose.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:markdown>` tag (or `<x-ui.markdown>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/markdown
- Registry JSON (files, props, contract): https://brokui.dev/r/open/markdown.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

long-content.blade.php Blade
@php
    $source = <<<'MARKDOWN'
    ## A heading with averyveryverylongunbrokenwordthatmustwrapinsteadofwideningthepage

    A paragraph with a long URL: https://example.com/a/very/long/unbroken/localized/path/that/must/wrap/without/forcing/the/page/to/scroll/horizontally?with=query&and=more

    ```text
    A code block line that is much wider than the column and scrolls inside its own box instead of the page: 0123456789abcdefghijklmnopqrstuvwxyz0123456789
    ```

    | Column one | Column two | Column three | Column four | Column five |
    | --- | --- | --- | --- | --- |
    | A cell with several words | https://example.com/another/long/unbroken/url/in/a/table/cell | More text | More text | More text |
    MARKDOWN;
@endphp

<div class="max-w-xs">
    <x-ui.markdown :source="$source" />
</div>
rtl.blade.php Blade
@php
    $source = <<<'MARKDOWN'
    ## ملاحظات الإصدار

    يعرض هذا المكون نص **Markdown** بأمان على الخادم. اقرأ [دليل التثبيت](https://example.com/docs).

    - القوائم تحافظ على علاماتها
      - وتتداخل في مستوى ثانٍ

    > تظهر الاقتباسات على خط في جهة البداية.

    | العنصر | السلوك |
    | --- | --- |
    | الجدول | يلتف داخل الغلاف |
    MARKDOWN;
@endphp

<div dir="rtl" lang="ar">
    <x-ui.markdown :source="$source" />
</div>
source-toggle.blade.php Blade
@php
    $source = <<<'MARKDOWN'
    ## Deploy checklist

    Run the **migrations** before the new code takes traffic, then warm the cache with `php artisan optimize`.

    - [x] Back up the database
    - [ ] Switch the queue workers
    - Read the [runbook](https://example.com/runbook) if a step fails

    | Step | Owner |
    | --- | --- |
    | Migrate | Platform |
    | Smoke test | QA |
    MARKDOWN;
@endphp

{{-- source-toggle: Formatted shows the rendered text, Source the raw Markdown it came from. --}}
<x-ui.markdown :source="$source" source-toggle class="w-full" />

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
source string The Markdown text to render. Pass untrusted text here; raw HTML in it is escaped and unsafe link schemes are removed. A non-string value renders nothing.
size md | sm | lg md Forwarded to prose: reading measure and base text size.
as string div Forwarded to prose: the tag for the text block (article, div, section or main). Defaults to div because Markdown usually sits inside a card or a column.
centered bool false Forwarded to prose: centers the bounded-width block. Off by default so the text aligns to the start edge of its container.
externalLinks none | nofollow | new-tab none Set as external-links. none leaves links as written; nofollow adds rel="nofollow noopener noreferrer" to links whose host differs from the app URL or the current request host; new-tab also adds target="_blank". Relative links are always internal.
headingOffset int 0 Set as heading-offset. Moves every heading down this many levels (0 to 5, capped at h6), so # renders as h2 with an offset of 1 when the page already has an h1.
sourceToggle bool false Set as source-toggle. Adds a Formatted / Source tab pair and a Copy Markdown button above the text; Source shows the raw Markdown escaped, in monospace, wrapped. Off by default, which renders exactly as before.

Slots

Default Blade slot only.

Renders as article, div, section, main.

Data slots

Stable hooks for CSS overrides and browser tests.

markdown markdown-rendered markdown-source markdown-toolbar prose-table-wrapper

Behavior

  • Converts the source with Laravel's Str::markdown (league/commonmark, which Laravel already requires) using html_input=escape, allow_unsafe_links=false and max_nesting_level=20; GitHub-flavoured tables, strikethrough, task lists and autolinks are on.
  • Content nested deeper than 20 levels is rendered as plain text, so a hostile document cannot exhaust the stack.
  • Code blocks and tables get tabindex="0" and scroll horizontally inside themselves; long words and URLs in paragraphs wrap, so the page never scrolls sideways.
  • Renders on the server only; it ships no JavaScript unless source-toggle is set.
  • With source-toggle, the tabs item switches between the rendered view and the raw source. Both views share one grid cell and the hidden one is only made invisible, so the block keeps the height of the taller view and nothing below it moves on a switch. Without JavaScript the rendered view shows and the inert tab row stays invisible.
  • Task-list checkboxes are read-only: each input is aria-hidden and a screen-reader-only 'Done:' or 'Not done:' before the item text states its state.
  • Declares registry capability flags: a11y, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Rich authoring and editing

Create or revise structured rich content.

Use when

  • Use when users need rich structured authoring, annotation, or collaborative editing controls.
  • Displaying Markdown written by users or stored in the database, such as comments, notes, descriptions or imported documents, as formatted read-only text.
  • Needing a server-only renderer that escapes raw HTML and drops javascript:, vbscript: and data: links without adding JavaScript or a composer package.

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • Users need to write or edit formatted text; use editor, which is an input surface.
  • The content is already trusted HTML (for example saved editor output); wrap it in prose with variant="rich" instead of converting it again.
  • The content needs raw HTML embeds, iframes or custom components; this renderer escapes all raw HTML on purpose.

Use instead

  • Textarea or plain field for simple content

Anti-patterns

  • Rich editors for one-line or easily validated input
Anatomy
root (data-slot=markdown) prose (variant=rich) prose-table-wrapper markdown-toolbar (source-toggle only) markdown-rendered (source-toggle only) markdown-source (source-toggle only)
Theming hooks
Styled entirely by prose variant="rich" through semantic tokens (foreground, muted, border, link, ring) in ui.css.

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
managed
  • Keeps the document's semantic elements (headings, lists, tables with th, blockquote, code) so assistive technology reads the structure; heading-offset keeps the outline under the page heading.
  • Scrollable code blocks and tables are keyboard focusable with the global focus ring, so keyboard users can scroll them.
  • Images keep the alt text written in the Markdown; authors must supply it.
  • The source toggle is a tablist named Markdown view with tabs semantics from the tabs item: arrow keys move between Formatted and Source, the active tab is aria-selected and each panel is labelled by its tab and keyboard focusable. The hidden panel is visibility hidden, so it is out of the accessibility tree and the tab order.
  • The new-tab link mode opens external links in a new tab without a spoken warning; prefer nofollow unless the product needs new tabs.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="markdown-{{ $record->id }}">
    @php
        $source = <<<'MARKDOWN'
        # Release notes
    
        Markdown from users renders **safely** on the server, with _emphasis_, ~~removed text~~ and `inline code`. Read the [install guide](https://example.com/docs/markdown) or visit https://example.com.
    
        ## What changed
    
        - Lists keep their markers
          - and nest a second level
            - and a third
        - [x] Task lists show their state
        - [ ] Open tasks stay unchecked
    
        1. Ordered steps count up
        2. Raw HTML such as <script>alert(1)</script> is shown as text
    
        > Quotes sit on a start-side rule that mirrors in right-to-left text.
    
        ```php
        $html = Str::markdown($comment->body, ['html_input' => 'escape']);
        ```
    
        ---
    
        | Element | Behaviour |
        | --- | ---: |
        | Code block | Scrolls inside itself |
        | Table | Scrolls inside its wrapper |
    
        ![The Brok social preview image](/og-default.png)
        MARKDOWN;
    @endphp
    
    <x-ui.markdown :source="$source" external-links="nofollow" />
</div>

Source

The exact, editable file ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/markdown.blade.php Blade
@props([
    'source' => '',
    'size' => 'md',
    'as' => 'div',
    'centered' => false,
    'externalLinks' => 'none',
    'headingOffset' => 0,
    // Opt-in: a Formatted / Source tab pair above the text. The rendered view
    // is the server default, so the text still reads with no JavaScript.
    'sourceToggle' => false,
])

@php
    $allowedTags = ['article', 'div', 'section', 'main'];
    $tag = in_array($as, $allowedTags, true) ? $as : 'div';
    $markdownSource = is_string($source) || $source instanceof Stringable ? (string) $source : '';
    $linkModes = ['none', 'nofollow', 'new-tab'];
    $linkMode = in_array($externalLinks, $linkModes, true) ? $externalLinks : 'none';
    $offset = max(0, min(5, (int) $headingOffset));
    $sourceToggle = filter_var($sourceToggle, FILTER_VALIDATE_BOOLEAN);

    $options = [
        'html_input' => 'escape',
        'allow_unsafe_links' => false,
        'max_nesting_level' => 20,
    ];
    $extensions = [];

    if ($linkMode !== 'none') {
        $internalHosts = array_values(array_unique(array_filter([
            parse_url((string) config('app.url'), PHP_URL_HOST),
            request()->getHost(),
        ], static fn (mixed $host): bool => is_string($host) && $host !== '')));

        $options['external_link'] = [
            'internal_hosts' => $internalHosts,
            'open_in_new_window' => $linkMode === 'new-tab',
            'nofollow' => 'external',
            'noopener' => 'external',
            'noreferrer' => 'external',
        ];
        $extensions[] = new \League\CommonMark\Extension\ExternalLink\ExternalLinkExtension;
    }

    $html = \Illuminate\Support\Str::markdown($markdownSource, $options, $extensions);

    if ($offset > 0) {
        $html = (string) preg_replace_callback(
            '/<(\/?)h([1-6])(?=[\s>])/',
            static fn (array $match): string => '<'.$match[1].'h'.min(6, (int) $match[2] + $offset),
            $html,
        );
    }

    // Task-list checkboxes are read-only state, not form controls: hide the
    // input from assistive technology and say its state in text instead.
    $html = str_replace(
        ['<input checked="" disabled="" type="checkbox">', '<input disabled="" type="checkbox">'],
        [
            '<input checked="" disabled="" type="checkbox" aria-hidden="true"><span class="sr-only">'.e(__('Done:')).' </span>',
            '<input disabled="" type="checkbox" aria-hidden="true"><span class="sr-only">'.e(__('Not done:')).' </span>',
        ],
        $html,
    );

    $html = str_replace(
        ['<pre>', '<table>', '</table>'],
        ['<pre tabindex="0" dir="ltr">', '<div data-slot="prose-table-wrapper" tabindex="0"><table>', '</table></div>'],
        $html,
    );
@endphp

@if ($sourceToggle)
{{--
    Source toggle: the tabs item owns the keyboard model (roving focus, arrow
    keys, aria-selected). Both panels share one grid cell and the hidden one
    is only made invisible, so the block keeps the height of the taller view
    and switching never moves the content below it. Before Alpine boots (or
    without JavaScript) the rendered panel shows and the inert tab row stays
    invisible.
--}}
<div data-slot="markdown" data-source-toggle {{ $attributes->merge(['class' => 'min-w-0']) }}>
    <x-ui.tabs default="rendered" class="!gap-2">
        <div data-slot="markdown-toolbar" class="invisible flex min-w-0 items-center justify-between gap-2" x-bind:class="{ 'invisible': false }">
            <x-ui.tabs.list :aria-label="__('Markdown view')">
                <x-ui.tabs.trigger value="rendered">{{ __('Formatted') }}</x-ui.tabs.trigger>
                <x-ui.tabs.trigger value="source">{{ __('Source') }}</x-ui.tabs.trigger>
            </x-ui.tabs.list>
            <x-ui.copy-button
                :value="$markdownSource"
                :label="__('Copy Markdown')"
                :copied-label="__('Copied')"
            />
        </div>
        <div class="grid min-w-0 grid-cols-1 *:col-start-1 *:row-start-1">
            <div
                data-slot="markdown-rendered"
                role="tabpanel"
                tabindex="0"
                class="min-w-0 rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
                x-bind:id="$id('tabs-panel', 'rendered')"
                x-bind:aria-labelledby="$id('tabs-trigger', 'rendered')"
                x-bind:class="{ 'invisible': ! isActive('rendered') }"
            >
                <x-ui.prose variant="rich" :size="$size" :as="$tag" :centered="$centered">
                    {{-- Same trusted renderer output as below; see the note there. --}}
                    {!! $html !!}
                </x-ui.prose>
            </div>
            <div
                data-slot="markdown-source"
                role="tabpanel"
                tabindex="0"
                class="invisible min-w-0 rounded-md bg-muted/40 p-4 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
                x-bind:id="$id('tabs-panel', 'source')"
                x-bind:aria-labelledby="$id('tabs-trigger', 'source')"
                x-bind:class="{ 'invisible': ! isActive('source') }"
            >
                <pre dir="auto" class="m-0 whitespace-pre-wrap wrap-anywhere font-mono text-sm leading-6 text-foreground"><code>{{ $markdownSource }}</code></pre>
            </div>
        </div>
    </x-ui.tabs>
</div>
@else
<div data-slot="markdown" {{ $attributes->merge(['class' => 'min-w-0']) }}>
    <x-ui.prose variant="rich" :size="$size" :as="$tag" :centered="$centered">
        {{--
            Unescaped output is safe here and only here: $html is produced by the
            CommonMark renderer from the source with html_input=escape (raw HTML
            becomes text), allow_unsafe_links=false (javascript:, vbscript:,
            file: and non-image data: URLs are dropped) and a bounded nesting
            depth. The post-processing above only rewrites tags the renderer
            itself emitted; escaped input can never contain a literal "<".
        --}}
        {!! $html !!}
    </x-ui.prose>
</div>
@endif

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.0
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0