Search by

sugarcraft / sugar-markdown

detain

PHP port of charmbracelet/glamour — Markdown → ANSI renderer with word-wrap, OSC 8 hyperlinks, syntax highlighting, and 9 stock themes (ansi/plain/dark/light/notty/dracula/tokyo-night/pink/ascii).

Package info

github.com/sugarcraft/sugar-markdown

Documentation

pkg:composer/sugarcraft/sugar-markdown

Statistics

Installs: 1 597

Dependents: 5

Suggesters: 0

Stars: 1

Open Issues: 0

dev-master 2026-11-04 11:58 UTC

This package is auto-updated.

Last update: 2026-11-05 01:02:14 UTC


README

sugar-markdown

SugarMarkdown

Formerly published as sugarcraft/candy-shine (renamed 2026-10); namespace SugarCraft\Shine\ → SugarCraft\Markdown\.

CI codecov Packagist Version License PHP

demo

sugar-markdown — a Markdown → ANSI renderer for PHP 8.3+, built on league/commonmark and sugar-style.

composer require sugarcraft/sugar-markdown

The Renderer exposes short-form aliases on every option: theme / sanitize / wordWrap / hyperlinks / baseURL / tableWrap / inlineTableLinks / tableColumnBudget / fenceColumnBudget / preservedNewLines / emoji / standardStyle. The long-form with* names still work — pick whichever reads better at the call site.

Quickstart

The example below embeds a fenced code block inside its Markdown sample, so the snippet itself is fenced with tildes: the outer fence conventionally differs from the inner ``` it contains (four backticks would also contain three by CommonMark, but the tilde spelling makes the nesting legible in the raw source).

Note: render() ends without a trailing newline on purpose — that byte sequence is exactly what stream() yields for a one-chunk document — so an echo adds its own "\n". write() and Writer follow the same law.

use SugarCraft\Markdown\Renderer;

echo (new Renderer())->render(<<<MD
# Welcome

A few **bold** and _italic_ words, with `inline code` and a
[link](https://example.com).

- one
- two
- three

```php
echo "hello world";
```
MD), "\n";

Themes

Stock presets:

use SugarCraft\Markdown\{Renderer, Theme};

new Renderer(Theme::ansi());        // default colourful
new Renderer(Theme::plain());       // no SGR
new Renderer(Theme::notty());       // alias for plain — non-TTY fallback
new Renderer(Theme::ascii());       // monochrome bold/italic/underline, no colour
new Renderer(Theme::dark());        // dark-bg optimised
new Renderer(Theme::light());       // light-bg optimised
new Renderer(Theme::dracula());     // #282a36 / #ff79c6 palette
new Renderer(Theme::tokyoNight());  // #1a1b26 / #7aa2f7
new Renderer(Theme::pink());        // playful sweet palette

Custom JSON theme (Theme::fromJson($path) reads the same JSON from a file; fromJsonString() takes it inline):

use SugarCraft\Markdown\{Renderer, Theme};

$theme = Theme::fromJsonString('{"heading1":{"foreground":"#ff5f87","bold":true}}');
echo (new Renderer($theme))->render("# Styled\n");

Two schemas are accepted. The one above is this library's FLAT schema; the flat reader decodes the listed style slots and leaves the rest (document margins and indent, affixes, heading prefixes, glyphs) at defaults. A payload that carries a document block is glamour's styles.json and routes through StyleGuide, which decodes the full element (h1, block_quote, code_block, …) and chroma token vocabulary — new StyleGuide helpers fromJsonString() / fromFile() / element() / theme() are public if you want the guide itself. Malformed JSON and unreadable files throw.

JSON shape (flat): an object keyed by element name (heading1, paragraph, bold, italic, code, codeBlock, link, blockquote, listMarker, rule, keyword, string, number, comment, strike, linkText, image, htmlBlock, htmlSpan, definitionTerm, definitionDescription, text, autolink); each value carries foreground / background (hex / ansi:N / ansi256:N) plus the SGR flags (bold, italic, underline, strike, faint, blink, reverse).

Word-wrap + OSC 8 hyperlinks

use SugarCraft\Markdown\{Renderer, Theme};

$renderer = (new Renderer(Theme::dark()))
    ->withWordWrap(80)
    ->withHyperlinks(true);

echo $renderer->render("# Word wrap\n");

withHyperlinks(true) (default) wraps every [text](url) in OSC 8 ; ; URL ST text OSC 8 ; ; ST so terminals that support it render real clickable links. Falls back to text (url) when off.

Link safety

Only the OSC-8 envelope is actionable; the (url) suffix is plain text. A refused scheme — javascript:, data:, vbscript: — never opens an OSC-8 target, so a hostile document cannot execute code through a click: the link text and its (url) suffix still render, visible and inert. Every other scheme passes to the terminal's opener unfiltered — treat non-http(s) / mailto targets the way you would treat a raw terminal. Control bytes (C0, DEL, ESC, BEL) are stripped from every URL unconditionally, and so are the invisible formatting characters (soft hyphen, Mongolian vowel separator, zero width space, word joiner, BOM, right-to-left override) before the scheme comparison — probes run over the raw, percent-decoded and HTML-entity-decoded spellings, so a refused scheme split by invisibles (java&#8203;script:) never becomes a target either. A link with an empty destination renders its text and nothing more — no () noise.

withSanitize(false) relaxes only the general text pipeline, for trusted input where control characters must survive: raw HTML blocks and inline HTML stay control-stripped regardless of the flag, and URL stripping is likewise always on. See the fleet-wide trust model in docs/TRUST-BOUNDARY.md.

Streaming

Renderer::stream() consumes Markdown chunk by chunk and yields styled ANSI as each top-level section is proven complete — so bytes can reach the terminal while the tail of the document is still arriving. Chunk boundaries never influence output: for every split of the same input bytes, implode('', iterator_to_array($renderer->stream($chunks))) equals $renderer->render($bytes) byte for byte.

use SugarCraft\Markdown\{Renderer, Theme};

$renderer = (new Renderer(Theme::dark()))->withWordWrap(80);
foreach ($renderer->stream(["# Heading\n\n", "body prose\n\n## Next heading\n"]) as $chunk) {
    echo $chunk; // finished sections land as soon as the next heading proves them closed
}

A section is proven complete when the next top-level ATX heading arrives (column one, after a blank line) or the input ends — the tail after the final heading never emits before the stream finishes. A section that still references an undefined link label is held (its source and first render both kept) and re-rendered whenever a later definition event could resolve it, so definition-first documents stream correctly at the cost of that hold.

Writer is the push-side twin: Writer::toPath($renderer, $path) or Writer::toStream($renderer, $resource) accept chunks through feed() and write every completed section through the sink immediately, one flush per section. close() renders the final section and finishes; the writer is single-use and feed() after close() throws. Renderers whose theme configures document-scope post-processing (documentMargin, documentIndent, block prefix/suffix) or that run withPreservedNewLines(true) buffer the whole document and emit once at the end — $renderer->defersStreaming() reports which regime applies. Renderer::writer($sink), Renderer::renderSection() and StreamSink are the same family's lower-level parts.

use SugarCraft\Markdown\{Renderer, Writer};

$writer = Writer::toStream(new Renderer(), STDOUT);
$writer->feed("# Live\n\n");
$writer->feed("first section proven\n");
$writer->close();

Resource limits (hostile input)

  • Block nesting is capped at Renderer::MAX_NESTED_BLOCK_DEPTH (32). Past the ceiling a blockquote or list still renders its content, but stops adding decoration — no extra bar, no markers, no re-wrap. Quote bodies stop re-wrapping below 4 usable cells, and at a 1-cell floor text letter-wraps; bytes are never dropped, and output grows linearly with the document, never per level.
  • There is no source-size cap: cost is roughly proportional to the input. Only syntax highlighting refuses to try — a fenced body over 1 MB degrades to the plain code-block style instead of tokenising.
  • The thematic break always draws horizontalRuleLength cells (default 40) regardless of the wrap width, and withTableWrap(true) wraps each cell at the full wrap width — pair it with withTableColumnBudget() to bound the whole table geometry.
  • The streaming channel keeps a section that waits on a link-reference definition in memory (source plus first render) until the definition or the end of input arrives; see Streaming above. A held section is re-rendered on each later definition event, bounded by the document's definition count, not by the number of chunks fed.

What it renders

  • Headings 1-6, paragraphs, **bold**, _italic_, ~~strike~~.
  • Inline code, fenced code blocks (with regex syntax highlighting for PHP / JS / TS / JSON / Python / Go / Bash / SQL / Rust / Java / C / C++ / Ruby / YAML / HTML / CSS), indented code.
  • Bullet + ordered + nested lists.
  • Block quotes (▎-prefixed).
  • GFM tables (rounded border, via the sugar-style table renderer SugarCraft\Style\Table\Table).
  • Task lists (☑ / ☐).
  • Definition lists (term / indented : description).
  • Links (with OSC 8 hyperlinks), autolinks, images (alt + url).
  • HTML blocks + inline HTML — pass through with theme styling.
  • Thematic breaks.

Authoring a custom theme

A Theme is a value object — every slot is a Style (or scalar). Build one with the constructor and feed it to new Renderer($theme):

use SugarCraft\Core\Util\Color;
use SugarCraft\Markdown\{Renderer, Theme};
use SugarCraft\Style\Style;

$theme = new Theme(
    heading1:  Style::new()->bold()->underline()->foreground(Color::hex('#ff5f87')),
    heading2:  Style::new()->bold()->foreground(Color::hex('#ffd700')),
    heading3:  Style::new()->bold()->foreground(Color::ansi(14)),
    heading4:  Style::new()->bold()->foreground(Color::ansi(12)),
    heading5:  Style::new()->bold()->foreground(Color::ansi(13)),
    heading6:  Style::new()->bold()->foreground(Color::ansi(10)),
    paragraph: Style::new(),
    bold:      Style::new()->bold(),
    italic:    Style::new()->italic(),
    code:      Style::new()->foreground(Color::hex('#ffd700')),
    codeBlock: Style::new()->faint(),
    link:      Style::new()->underline()->foreground(Color::ansi(12)),
    blockquote: Style::new()->italic()->foreground(Color::ansi(8)),
    listMarker: Style::new()->foreground(Color::hex('#ff5f87')),
    rule:      Style::new()->foreground(Color::ansi(8)),

    // Element extensions:
    headingPrefix:    '❯ ',
    headingCase:      'upper',
    paragraphPrefix:  '  ',
    documentMargin:   1,
    listLevelIndent:  4,
    taskTickedGlyph:  '✓',
    taskUntickedGlyph:'·',
    horizontalRuleGlyph: '═',
    horizontalRuleLength: 60,
);

echo (new Renderer($theme))->render("# Custom\n");

The full slot reference (left-to-right reading the constructor):

Block Slots
Headings heading1 … heading6 (with headingPrefix, headingSuffix, headingCase)
Paragraphs paragraph (+ paragraphPrefix / paragraphSuffix)
Inline bold · italic · strike · code · link · linkText · autolink · image · imageText · text
Block codeBlock · blockquote · rule · listMarker · htmlBlock · htmlSpan
Document documentMargin · documentIndent · documentBlockPrefix / Suffix
Lists orderedListMarker · unorderedListMarker · orderedListMarkerFormat · unorderedListMarkerGlyph · listLevelIndent
Task list taskTickedGlyph · taskUntickedGlyph
Horizontal rule horizontalRuleGlyph · horizontalRuleLength
Tables tableHeader · tableCell · tableSeparator · tableCenterSeparator · tableColumnSeparator · tableRowSeparator
Definition lists definitionTerm · definitionDescription · definitionList
Syntax highlighting keyword · string · number · comment

tableHeader defaults to null = no header styling (set it explicitly for bold headers). tableSeparator is reserved: borders draw their glyphs from tableColumnSeparator / tableCenterSeparator / tableRowSeparator, and the style slot is not applied to them yet.

Stock themes (Theme::ansi(), Theme::dark(), Theme::dracula(), Theme::tokyoNight(), Theme::pink(), Theme::light(), Theme::ascii(), Theme::notty(), Theme::plain()) are good starting points — copy the constructor call and adjust the slots you care about.

Theme::fromEnvironment(?$default) (also Renderer::fromEnvironment()) resolves the theme from the environment without code changes: a non-empty NO_COLOR wins first and returns plain(); otherwise GLAMOUR_STYLE names a preset (case-insensitive, hyphen / underscore tolerant), the literal auto picks dark() on a TTY and notty() off one, and an unknown name falls back to $default (default ansi()). new Renderer() keeps its own ansi() hard default — honouring the environment is exactly what this channel is for. examples/render.php prints an extra section when either variable is set:

GLAMOUR_STYLE=tokyo-night php examples/render.php
NO_COLOR=1 php examples/render.php

Renderer options

use SugarCraft\Markdown\{Renderer, Theme};

$renderer = (new Renderer(Theme::dark()))
    ->withWordWrap(80)               // wrap paragraphs / blockquotes / lists
                                     // (headings, code blocks, tables never)
    ->withHyperlinks(true)           // emit OSC 8 link envelopes
    ->withBaseURL('https://docs.example.com/')  // prefix relative links
    ->withTableWrap(true)            // wrap text inside table cells
    ->withInlineTableLinks(false)    // suppress (url) suffix in cells
    ->withTableColumnBudget(true)    // scale table columns into the wrap
    ->withFenceColumnBudget(true)    // clip fenced lines to the wrap
    ->withPreservedNewLines(true)    // keep source blank-line runs between blocks
    ->withStandardStyle('dracula')   // re-pick the stock theme
    ->withEmoji(true);               // expand `:smile:` shortcodes

echo $renderer->render("Hello :wave:\n");

With emoji on, expansion runs after parsing — code spans, fenced blocks and link destinations keep the source bytes. 1,842 codes resolve: the 1,837-entry GitHub cheat-sheet corpus (GithubEmoji::MAP) merged with 39 curated house codes; the 34 names both spellings share resolve to the house bytes. Unknown :codes: pass through verbatim.

Renderer::renderMarkdown($md, ?Theme) is a one-shot static convenience for ad-hoc rendering. For repeated renders with the same theme, build a Renderer and reuse it (the parser is cached per instance). A Renderer is not reentrant: each render resets its block stack, so reuse renders sequentially, not concurrently from one object.

API at a glance

  • Constructors / factories: new Renderer(?Theme, ?int $wrapWidth, ...), Renderer::ansi(), Renderer::plain(), Renderer::ascii(), Renderer::fromEnvironment(), one-shot Renderer::renderMarkdown($md, ?Theme).
  • Channels: render() (whole string), stream() (chunk generator), renderSection() / sectionBodies() (per-section), write() (render into a stream resource), writer() / Writer::toPath() / Writer::toStream() (push side), StreamSink (the sink both share).
  • Readback: defersStreaming() tells buffered from streaming regime; Theme::byName(), Theme::fromEnvironment(), Theme::fromJson() and Theme::fromJsonString() complete the theme side.

Test

cd sugar-markdown && vendor/bin/phpunit

Inside the monorepo, refresh sibling vendors first (path-linked): php scripts/refresh-deps.php --mode=linked from the repo root. A bare composer install in the standalone split repo resolves siblings from Packagist instead.

Demos

Render

render

Themes

themes

Snapshot tests

Render output is covered by golden-file snapshot tests. Fixture files live in tests/fixtures/ with a .golden extension and are compared against actual ANSI byte output via SugarCraft\Testing\Snapshot\Assertions::assertGoldenAnsi(). The flag is create-only: an existing fixture that differs still fails and is never rewritten. To re-baseline after intentional output changes, delete the fixture and re-record it:

rm tests/fixtures/<name>.golden
UPDATE_GOLDENS=1 vendor/bin/phpunit   # recreates the missing file

Related

Credits & inspiration

Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.