Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ echo $highlighter->highlight($code, 'php');
`<pre class="alto-highlight"><code>…</code></pre>`. Emit a theme stylesheet
once per page, then reuse the highlighter for every code block.

## Parse without rendering

Use `CodeParser` when another component needs semantic tokens instead of HTML:

```php
use Alto\Code\Highlight\CodeParser;

$stream = (new CodeParser())->parse(
'$total = array_sum($prices);',
'php',
);

foreach ($stream as $token) {
echo $token->text.' '.$token->scope->value.PHP_EOL;
}
```

`parse()` returns a `ParsedStream` and preserves the caller's source exactly:
`$stream->toString()` is the original code. It also resolves configured
embedded languages, without requiring a theme or choosing an output format.

## What it covers

- **27 languages:** the PHP web stack plus common programming, markup, data,
Expand All @@ -76,7 +97,7 @@ once per page, then reuse the highlighter for every code block.
| [Create a theme](docs/theming/creating.md) | Implement `ThemeInterface` |
| [Embedded languages](docs/languages/embedded.md) | HTML, SVG, Markdown, and Twig |
| [Theme adapters](docs/theming/adapters.md) | Highlight.js, Prism, and TextMate |
| [Public API](docs/api/index.md) | Supported entry points and extension contracts |
| [Public API](docs/api/index.md) | Parsing, rendering, and extension contracts |
| [Examples](docs/examples.md) | Compact examples and generated previews |

The complete source examples are available in [`examples/languages/`](examples/languages/).
Expand Down
24 changes: 20 additions & 4 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@ Alto Code Highlight follows semantic versioning for the supported entry points
and extension contracts described here. Patch and minor releases preserve
their documented signatures and behavior throughout the 1.x series.

## Main entry point
## Parsing

`Highlighter` is the primary facade. Its supported operations are:
`CodeParser` parses source into a `ParsedStream` without rendering it. Its
supported operations are:

- construction with an optional embedding registry and optional language list;
- `parse()` for semantically scoped tokens;
- `registerLanguage()` for adding or replacing a parser;
- `getEmbeddedRegistry()` for inspecting embedding plans;
- `setEmbeddingEnabled()` for toggling a configured host and target pair.

`parse()` preserves the source supplied by the caller. Concatenating the token
text, or calling `ParsedStream::toString()`, returns that source exactly.
Selecting `php` parses PHP from the first byte even when the opening tag is
omitted.

## HTML rendering

`Highlighter` renders the same parsed representation as escaped HTML. Its
supported operations are:

- construction with a `ThemeInterface`, optional embedding registry, and
optional language list;
Expand All @@ -17,8 +34,7 @@ their documented signatures and behavior throughout the 1.x series.
- `setEmbeddingEnabled()` for toggling a configured host and target pair.

`HighlighterInterface` defines the portable highlighting operation for code
that depends on an abstraction rather than the concrete facade. Selecting
`php` parses PHP from the first byte even when the opening tag is omitted.
that depends on an abstraction rather than the concrete facade.

## Theme extension contract

Expand Down
5 changes: 4 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,15 @@ require a browser-side highlighter.

## Public API at a glance

`Alto\Code\Highlight\Highlighter` is the main entry point:
Use `CodeParser` when you need tokens, and `Highlighter` when you need HTML:

```php
use Alto\Code\Highlight\CodeParser;
use Alto\Code\Highlight\Highlighter;
use Alto\Code\Highlight\Theme\AltoTheme;

$tokens = (new CodeParser())->parse('$answer = 42;', 'php');

$highlighter = new Highlighter(new AltoTheme());
$html = $highlighter->highlight('<?php echo "Hello";', 'php');
$css = $highlighter->getTheme()->getStylesheet();
Expand Down
139 changes: 139 additions & 0 deletions src/CodeParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

/*
* This file is part of the ALTO library.
*
* © 2026-present Simon André
*
* For full copyright and license information, please see
* the LICENSE file distributed with this source code.
*/

namespace Alto\Code\Highlight;

use Alto\Code\Highlight\Embedded\EmbeddedLanguagePlan;
use Alto\Code\Highlight\Embedded\EmbeddedLanguageRegistry;
use Alto\Code\Highlight\Exception\LanguageNotFoundException;
use Alto\Code\Highlight\Language\EmbeddedLanguageCapable;
use Alto\Code\Highlight\Language\EmbeddedLanguageContext;
use Alto\Code\Highlight\Language\LanguageInterface;
use Alto\Code\Highlight\Language\Languages;
use Alto\Code\Highlight\Parser\ParsedStream;

/**
* Parses source code into a stream of semantically scoped tokens.
*
* @author Simon André <smn.andre@gmail.com>
*/
final class CodeParser
{
/**
* @var array<string, LanguageInterface>
*/
private array $languages = [];

private EmbeddedLanguageRegistry $embeddedRegistry;

/**
* @var array<string, bool>
*/
private array $embeddedLanguageToggles = [];

/**
* @param list<LanguageInterface>|null $languages
*/
public function __construct(
?EmbeddedLanguageRegistry $embeddedRegistry = null,
?array $languages = null,
) {
$this->embeddedRegistry = $embeddedRegistry ?? new EmbeddedLanguageRegistry();

$languages ??= Languages::getDefaultLanguages();
foreach ($languages as $language) {
if (!$language instanceof LanguageInterface) {
throw new \InvalidArgumentException('All languages must implement LanguageInterface.');
}

$this->registerLanguage($language);
}
}

/**
* Parse source code without rendering it.
*/
public function parse(string $code, string $language): ParsedStream
{
$language = strtolower(trim($language));

if ('php-snippet' === $language) {
$language = 'php';
}

return $this->parseWithLanguage($this->getLanguage($language), $code);
}

/**
* Register a language parser.
*/
public function registerLanguage(LanguageInterface $language): void
{
$this->languages[$language->getIdentifier()] = $language;
}

public function getEmbeddedRegistry(): EmbeddedLanguageRegistry
{
return $this->embeddedRegistry;
}

/**
* Enable or disable a specific embedded language.
*
* @param string $host The host language (e.g., 'html')
* @param string $target The embedded language to toggle (e.g., 'javascript')
*/
public function setEmbeddingEnabled(string $host, string $target, bool $enabled): void
{
$this->embeddedLanguageToggles[strtolower($host) . ':' . strtolower($target)] = $enabled;
}

/**
* @throws LanguageNotFoundException
*/
private function getLanguage(string $identifier): LanguageInterface
{
if (!isset($this->languages[$identifier])) {
throw new LanguageNotFoundException($identifier);
}

return $this->languages[$identifier];
}

private function parseWithLanguage(LanguageInterface $language, string $code): ParsedStream
{
if ($language instanceof EmbeddedLanguageCapable) {
$plan = $this->embeddedRegistry->getPlan($language->getIdentifier());

if (null !== $plan) {
$host = $language->getIdentifier();
$triggers = array_filter($plan->getTriggers(), function ($trigger) use ($host) {
$key = $host . ':' . $trigger->targetLanguage;

return $this->embeddedLanguageToggles[$key] ?? true;
});
$plan = EmbeddedLanguagePlan::forHost($host, array_values($triggers));
}

$context = EmbeddedLanguageContext::fromResolver(function (string $identifier, string $embeddedCode): ParsedStream {
$embeddedLanguage = $this->getLanguage($identifier);

return $this->parseWithLanguage($embeddedLanguage, $embeddedCode);
}, $plan);

return $language->parseWithEmbedding($code, $context);
}

return $language->parse($code);
}
}
86 changes: 6 additions & 80 deletions src/Highlighter.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,8 @@

namespace Alto\Code\Highlight;

use Alto\Code\Highlight\Embedded\EmbeddedLanguagePlan;
use Alto\Code\Highlight\Embedded\EmbeddedLanguageRegistry;
use Alto\Code\Highlight\Exception\LanguageNotFoundException;
use Alto\Code\Highlight\Language\EmbeddedLanguageCapable;
use Alto\Code\Highlight\Language\EmbeddedLanguageContext;
use Alto\Code\Highlight\Language\LanguageInterface;
use Alto\Code\Highlight\Language\Languages;
use Alto\Code\Highlight\Parser\ParsedStream;

/**
Expand All @@ -31,17 +26,7 @@
*/
final class Highlighter implements HighlighterInterface
{
/**
* @var array<string, LanguageInterface>
*/
private array $languages = [];

private EmbeddedLanguageRegistry $embeddedRegistry;

/**
* @var array<string, bool>
*/
private array $embeddedLanguageToggles = [];
private CodeParser $parser;

/**
* @param list<LanguageInterface>|null $languages
Expand All @@ -51,17 +36,7 @@ public function __construct(
?EmbeddedLanguageRegistry $embeddedRegistry = null,
?array $languages = null,
) {
$this->embeddedRegistry = $embeddedRegistry ?? new EmbeddedLanguageRegistry();

// Register built-in languages
$languages ??= Languages::getDefaultLanguages();
foreach ($languages as $language) {
if (!$language instanceof LanguageInterface) {
throw new \InvalidArgumentException('All languages must implement LanguageInterface.');
}

$this->registerLanguage($language);
}
$this->parser = new CodeParser($embeddedRegistry, $languages);
}

/**
Expand All @@ -88,11 +63,7 @@ public function highlight(
$language = 'php';
}

// Get the language parser
$languageParser = $this->getLanguage($language);

// Parse the code (recursively handling embedded languages when supported)
$parsedStream = $this->parseWithLanguage($languageParser, $code);
$parsedStream = $this->parser->parse($code, $language);

// Format the output
return $this->format($parsedStream, $language, $lineNumbers, $highlightLines);
Expand All @@ -103,7 +74,7 @@ public function highlight(
*/
public function registerLanguage(LanguageInterface $language): void
{
$this->languages[$language->getIdentifier()] = $language;
$this->parser->registerLanguage($language);
}

/**
Expand All @@ -114,54 +85,9 @@ public function getTheme(): ThemeInterface
return $this->theme;
}

/**
* Get a registered language parser.
*
* @throws LanguageNotFoundException
*/
private function getLanguage(string $identifier): LanguageInterface
{
if (!isset($this->languages[$identifier])) {
throw new LanguageNotFoundException($identifier);
}

return $this->languages[$identifier];
}

/**
* Parse source code with the given language, handling embedded delegation when available.
*/
private function parseWithLanguage(LanguageInterface $language, string $code): ParsedStream
{
if ($language instanceof EmbeddedLanguageCapable) {
$plan = $this->embeddedRegistry->getPlan($language->getIdentifier());

if (null !== $plan) {
$host = $language->getIdentifier();
$triggers = array_filter($plan->getTriggers(), function ($trigger) use ($host) {
$key = $host . ':' . $trigger->targetLanguage;

return $this->embeddedLanguageToggles[$key] ?? true;
});
// Create a new plan with filtered triggers
$plan = EmbeddedLanguagePlan::forHost($host, array_values($triggers));
}

$context = EmbeddedLanguageContext::fromResolver(function (string $identifier, string $embeddedCode): ParsedStream {
$embeddedLanguage = $this->getLanguage($identifier);

return $this->parseWithLanguage($embeddedLanguage, $embeddedCode);
}, $plan);

return $language->parseWithEmbedding($code, $context);
}

return $language->parse($code);
}

public function getEmbeddedRegistry(): EmbeddedLanguageRegistry
{
return $this->embeddedRegistry;
return $this->parser->getEmbeddedRegistry();
}

/**
Expand All @@ -172,7 +98,7 @@ public function getEmbeddedRegistry(): EmbeddedLanguageRegistry
*/
public function setEmbeddingEnabled(string $host, string $target, bool $enabled): void
{
$this->embeddedLanguageToggles[strtolower($host) . ':' . strtolower($target)] = $enabled;
$this->parser->setEmbeddingEnabled($host, $target, $enabled);
}

/**
Expand Down
Loading