sugarcraft / sugar-markdown
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).
Requires
- php: ^8.3
- league/commonmark: ^2.4
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-style: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/sugar-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:02:14 UTC
README
SugarMarkdown
Formerly published as
sugarcraft/candy-shine(renamed 2026-10); namespaceSugarCraft\Shine\→SugarCraft\Markdown\.
sugar-markdown — a Markdown → ANSI renderer for PHP 8.3+, built on
league/commonmark and sugar-style.
composer require sugarcraft/sugar-markdown
The
Rendererexposes short-form aliases on every option:theme/sanitize/wordWrap/hyperlinks/baseURL/tableWrap/inlineTableLinks/tableColumnBudget/fenceColumnBudget/preservedNewLines/emoji/standardStyle. The long-formwith*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 whatstream()yields for a one-chunk document — so anechoadds its own"\n".write()andWriterfollow 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​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
horizontalRuleLengthcells (default 40) regardless of the wrap width, andwithTableWrap(true)wraps each cell at the full wrap width — pair it withwithTableColumnBudget()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-shotRenderer::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()andTheme::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
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
- SugarCraft monorepo
- Originally inspired by charmbracelet/glamour
Credits & inspiration
Originally inspired by the Go Charm ecosystem; SugarCraft is developed as a native PHP project.

