diff --git a/.gitattributes b/.gitattributes index 58fbb60..3611f27 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,8 +7,14 @@ /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /bin/ export-ignore +/docs/ export-ignore +/examples/ export-ignore /tests/ export-ignore +/tools/ export-ignore /bin/** linguist-vendored +/docs/** linguist-documentation +/examples/** linguist-documentation /tests/** linguist-vendored /tests/Language/**/*.html linguist-generated +/tools/** linguist-vendored diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f54c40..71779e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - Unreleased +## [1.0.0] - 2026-08-09 ### Added -- Semantic syntax highlighting engine with context-aware PHP parsing -- 27 language parsers: PHP, HTML, SVG, XML, CSS, SCSS, JavaScript, TypeScript, Twig, Markdown, YAML, JSON, SQL, Bash, Go, Rust, Ruby, Swift, Python, Java, C#, Dockerfile, Diff, DotEnv, HTTP, INI, Makefile -- Embedded language support for HTML (`"; - -// 3. Highlight your code -echo $highlighter->highlight($code, 'php'); -``` +Requirements: -## Languages +- PHP 8.4 or later; +- `ext-mbstring`; +- `ext-tokenizer`. -### PHP +See the [installation guide](docs/installation.md) for verification and +troubleshooting. -Alto uses a semantic parser for PHP that goes beyond pattern matching to -understand code context. It correctly distinguishes between: +## Quick start -- **Definitions vs. usage:** `class User` vs. `new User()`, `function greet()` - vs. `greet()` -- **Context-aware scoping:** Variables, function calls, class instantiation, - method calls +```php +` tags, JavaScript in ` +HTML; + +$html = $highlighter->highlight($source, 'html'); +``` + +The tag and attributes use the host parser. Text up to the matching closing tag +uses the `css` or `javascript` parser. + +## Markdown fences + +Use an exact registered identifier in the fence: + +````markdown +```php +echo "Embedded PHP"; +``` +```` + +Embedded PHP may omit its opening tag. The highlighter adds one for parsing and +removes the synthetic tag from the rendered result. + +If the fence has no identifier, its content is rendered as code text. If the +identifier is unknown or an embedded parser fails, the host highlight still +succeeds and keeps that content visible with the generic string scope. + +## Named Twig blocks + +Create a registry that replaces the empty default Twig plan while preserving +the other defaults: + +```php +use Alto\Code\Highlight\Embedded\EmbeddedLanguagePlan; +use Alto\Code\Highlight\Embedded\EmbeddedLanguageRegistry; +use Alto\Code\Highlight\Embedded\EmbeddedTrigger; +use Alto\Code\Highlight\Highlighter; +use Alto\Code\Highlight\Theme\AltoTheme; + +$plans = EmbeddedLanguageRegistry::getDefaultPlans(); +$plans[] = EmbeddedLanguagePlan::forHost('twig', [ + EmbeddedTrigger::block('css', 'css'), + EmbeddedTrigger::block('javascript', 'javascript'), +]); + +$registry = new EmbeddedLanguageRegistry($plans); +$highlighter = new Highlighter(new AltoTheme(), $registry); + +$html = $highlighter->highlight( + '{% block javascript %}const ready = true;{% endblock %}', + 'twig', +); +``` + +Block names and target identifiers are normalized to lowercase. + +Passing a custom registry to `Highlighter` does not merge plans automatically. +The example starts with `getDefaultPlans()` so HTML, SVG, and Markdown keep +their built-in behavior. A later plan for the same host replaces the earlier +one. + +## Customize tag triggers + +Attribute constraints can route a tag to a different parser. Put a constrained +trigger before a generic trigger for the same tag: + +```php +$registry = new EmbeddedLanguageRegistry([ + EmbeddedLanguagePlan::forHost('html', [ + EmbeddedTrigger::tag('style', 'css'), + EmbeddedTrigger::tag('script', 'typescript', [ + 'type' => ['text/typescript', 'application/typescript'], + ]), + EmbeddedTrigger::tag('script', 'javascript'), + ]), +]); +``` + +Constraint names and values are compared case-insensitively. A `null` +constraint requires only that the attribute be present. + +## Toggle a host/target pair + +Configured tag or block triggers are enabled by default: + +```php +$highlighter->setEmbeddingEnabled('html', 'javascript', false); +$plainScript = $highlighter->highlight($source, 'html'); + +$highlighter->setEmbeddingEnabled('html', 'javascript', true); +$parsedScript = $highlighter->highlight($source, 'html'); +``` + +The toggle is stored per highlighter instance and per normalized +`host:target` pair. Disabling a pair keeps the embedded source visible, but the +host parser treats it as markup text instead of delegating it. + +Dynamic Markdown fences are not declarative triggers, so +`setEmbeddingEnabled()` does not toggle them. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..ac92862 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,107 @@ +# Examples + +The repository contains one curated, compact source example for each of the 27 +default languages. The same stable inputs drive documentation previews and +parser checks. + +Browse the complete source catalog in +[`examples/languages/`](../examples/languages/), or use the individual links in +the [language reference](languages.md). + +## Featured preview matrix + +Each image is generated at 800 × 400 pixels from the real public highlighter +API. + +### PHP + +| Alto Dark | Alto Light | +|---|---| +|  |  | + +| GitHub Dark | GitHub Light | +|---|---| +|  |  | + +### Twig + +| Alto Dark | Alto Light | +|---|---| +|  |  | + +| GitHub Dark | GitHub Light | +|---|---| +|  |  | + +### HTML + +| Alto Dark | Alto Light | +|---|---| +|  |  | + +| GitHub Dark | GitHub Light | +|---|---| +|  |  | + +### JavaScript + +| Alto Dark | Alto Light | +|---|---| +|  |  | + +| GitHub Dark | GitHub Light | +|---|---| +|  |  | + +### CSS + +| Alto Dark | Alto Light | +|---|---| +|  |  | + +| GitHub Dark | GitHub Light | +|---|---| +|  |  | + +## Generate previews locally + +The isolated showcase tool can render a specific language/theme pair, one +language across themes, one theme across languages, or its default featured +matrix: + +```bash +cd tools/docs-showcase +composer install +npm install +npx playwright install chromium + +npm run refresh +npm run generate -- --language=php +npm run generate -- --theme=alto-dark +npm run generate -- --language=php --theme=github-light +npm run capture -- --language=php --theme=github-light +``` + +`refresh` generates, captures, and verifies the featured matrix. Use `--all` +with the `generate`, `capture`, and `verify` commands for the complete +language/theme matrix. Generated intermediate HTML stays under +`tools/docs-showcase/build/`; published images are written under +`docs/assets/examples/`. + +The generator accepts every registered language and every built-in theme +variant. Its default publication set is the five languages shown above across +Alto Dark, Alto Light, GitHub Dark, and GitHub Light. + +## Example contract + +Every canonical source file: + +- stays within an 8-13 visible-line budget; +- uses the language's exact public identifier in the catalog; +- contains representative, deterministic source; +- reconstructs exactly after highlighting; +- fits in the fixed-size preview without wrapping or clipping. + +The catalog and verification tools reject missing, duplicate, or unknown +language entries. See [Creating a theme](creating-a-theme.md) to use these +samples when reviewing a custom theme. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..7713275 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,164 @@ +# Getting started + +This guide renders a complete HTML page with one highlighted PHP example. + +## Render a code block + +```php +highlight($code, 'php'); +?> + + +
+ +`, ``, and `` tags as text.
+
+## Construct a highlighter
+
+The concrete constructor accepts a theme and two optional custom registries:
+
+```php
+$highlighter = new Highlighter(
+ theme: $theme,
+ embeddedRegistry: null,
+ languages: null,
+);
+```
+
+Passing `null` uses the built-in embedding plans and all 27 default languages.
+The `languages` argument accepts a list of `LanguageInterface`
+implementations. It replaces the default list rather than extending it; call
+`registerLanguage()` after construction when you only need to add or replace
+one parser.
+
+## `Highlighter::highlight()`
+
+The method accepts four arguments:
+
+```php
+interface HighlighterInterface
+{
+ public function highlight(
+ string $code,
+ string $language,
+ bool $lineNumbers = false,
+ array $highlightLines = [],
+ ): string;
+}
+```
+
+- `$code` is the source text.
+- `$language` is an exact [registered identifier](languages.md).
+- `$lineNumbers` adds a numbered span at the start of every line.
+- `$highlightLines` is a list of 1-indexed line numbers. Highlighted numbers
+ receive the `alto-highlighted` class.
+
+Use named arguments when enabling the optional features:
+
+```php
+$html = $highlighter->highlight(
+ $code,
+ 'php',
+ lineNumbers: true,
+ highlightLines: [3, 6],
+);
+```
+
+Built-in themes color syntax tokens but do not prescribe line-number
+presentation. Add application CSS for the two structural classes:
+
+```css
+.alto-line-number {
+ display: inline-block;
+ width: 3rem;
+ color: color-mix(in srgb, currentColor 55%, transparent);
+ user-select: none;
+}
+
+.alto-line-number.alto-highlighted {
+ color: inherit;
+ font-weight: 700;
+}
+```
+
+`alto-highlighted` is applied to the line-number span, not to a wrapper around
+the full source line.
+
+## Handle an unknown language
+
+Unknown identifiers throw `LanguageNotFoundException`:
+
+```php
+use Alto\Code\Highlight\Exception\LanguageNotFoundException;
+
+try {
+ $html = $highlighter->highlight($code, $requestedLanguage);
+} catch (LanguageNotFoundException $exception) {
+ $html = ''.
+ htmlspecialchars($code, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').
+ '
';
+}
+```
+
+The highlighter lowercases and trims the identifier. It does not infer a
+language from a filename and does not expand aliases.
+
+## Emit theme CSS once
+
+`getTheme()` returns the same theme instance passed to the constructor:
+
+```php
+$stylesheet = $highlighter->getTheme()->getStylesheet();
+```
+
+Place that stylesheet once in the document `` or in a cached CSS asset.
+Do not emit it for every code block. The same `Highlighter` instance can render
+multiple blocks with the selected theme.
+
+To switch themes, create the requested theme and a corresponding highlighter
+before rendering the page. See the [built-in theme variants](themes.md).
+
+## Other public operations
+
+`Highlighter` also exposes:
+
+- `registerLanguage()` to add or replace a parser by its identifier;
+- `getEmbeddedRegistry()` to inspect the active embedding plans;
+- `setEmbeddingEnabled()` to toggle a configured host/target pair.
+
+See [Embedded languages](embedded-languages.md) for the embedding contracts.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..d4d8092
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,45 @@
+# Alto Code Highlight documentation
+
+Alto Code Highlight is a server-side syntax highlighter for PHP 8.4 and later.
+It parses source code in PHP and returns escaped, theme-ready HTML. It does not
+require a browser-side highlighter.
+
+## Start here
+
+- [Installation](installation.md) covers requirements, Composer, and a smoke
+ test.
+- [Getting started](getting-started.md) goes from source code to a complete HTML
+ page.
+- [Languages](languages.md) lists every accepted language identifier.
+- [Themes](themes.md) lists all built-in theme variants and their constructors.
+
+## Guides
+
+- [Embedded languages](embedded-languages.md) explains HTML, SVG, Markdown, and
+ Twig delegation.
+- [Theme adapters](theme-adapters.md) shows how to reuse local Highlight.js,
+ Prism, or TextMate theme files.
+- [Creating a theme](creating-a-theme.md) implements `ThemeInterface` from
+ semantic scopes to CSS.
+- [Public API](public-api.md) defines the supported entry points, extension
+ contracts, and compatibility boundary.
+- [Examples](examples.md) links to the canonical source samples and generated
+ visual previews.
+
+## Public API at a glance
+
+`Alto\Code\Highlight\Highlighter` is the main entry point:
+
+```php
+use Alto\Code\Highlight\Highlighter;
+use Alto\Code\Highlight\Theme\AltoTheme;
+
+$highlighter = new Highlighter(new AltoTheme());
+$html = $highlighter->highlight('getTheme()->getStylesheet();
+```
+
+The returned HTML is a `` element containing a
+`` element and semantic `` elements. Source text is HTML-escaped
+during rendering. Add the selected theme's stylesheet once to the page, then
+insert the returned HTML without escaping it again.
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..f785511
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,99 @@
+# Installation
+
+## Requirements
+
+Alto Code Highlight requires:
+
+- PHP 8.4 or later;
+- the `mbstring` extension;
+- the `tokenizer` extension;
+- Composer.
+
+The package has no PHP package dependencies at runtime. It also needs no
+Node.js process or client-side syntax highlighter.
+
+## Install with Composer
+
+Run this command in your project:
+
+```bash
+composer require alto/code-highlight
+```
+
+Framework applications normally load Composer's autoloader for you. In a
+standalone PHP script, load it explicitly:
+
+```php
+require __DIR__.'/vendor/autoload.php';
+```
+
+## Smoke test
+
+Save this as `highlight.php` in the project root:
+
+```php
+\n".$highlighter->getTheme()->getStylesheet()."\n\n";
+echo $highlighter->highlight($code, 'php');
+```
+
+Run it and write the resulting HTML to a file:
+
+```bash
+php highlight.php > highlight.html
+```
+
+Open `highlight.html` in a browser. It should contain a dark Alto code block
+with highlighted PHP.
+
+## Common installation failures
+
+### Composer rejects the PHP version
+
+Check the CLI version used by Composer:
+
+```bash
+php --version
+composer check-platform-reqs
+```
+
+The package requires PHP 8.4 or later.
+
+### A required extension is missing
+
+Check both extensions in the same PHP runtime used by Composer:
+
+```bash
+php --ri mbstring
+php --ri tokenizer
+```
+
+Install or enable the missing extension, then rerun Composer.
+
+### The autoloader cannot be found
+
+Run `composer install` in the project and make the `require` path relative to
+the script that executes it. A framework bootstrap usually already includes
+`vendor/autoload.php`.
+
+### A language is reported as unsupported
+
+Use an exact identifier from the [language reference](languages.md). The
+highlighter normalizes case and surrounding whitespace, but does not provide
+aliases such as `js`, `ts`, `sh`, `yml`, or `cs`.
+
+## Next step
+
+Continue with [Getting started](getting-started.md) for a complete page,
+line-number options, and error handling.
diff --git a/docs/languages.md b/docs/languages.md
new file mode 100644
index 0000000..a1120df
--- /dev/null
+++ b/docs/languages.md
@@ -0,0 +1,92 @@
+# Languages
+
+The default registry contains 27 languages. Pass the identifier in the
+**Identifier** column to `Highlighter::highlight()`.
+
+Identifiers are case-insensitive after trimming, but there are no short
+aliases. Use `javascript`, not `js`; `typescript`, not `ts`; `bash`, not `sh`;
+`yaml`, not `yml`; and `csharp`, not `cs`.
+
+## Default registry
+
+| Language | Identifier | Category | Typical extension | Parsing focus | Example |
+|---|---|---|---|---|---|
+| Bash | `bash` | Shell | `.sh` | Shell keywords, built-ins, variables, strings, and here-docs | [Source](../examples/languages/bash.sh) |
+| C# | `csharp` | Programming | `.cs` | Two-pass definitions, calls, and type references | [Source](../examples/languages/csharp.cs) |
+| CSS | `css` | Stylesheet | `.css` | Selectors, declarations, values, comments, and at-rules | [Source](../examples/languages/css.css) |
+| Diff | `diff` | Change | `.diff` | File headers, hunks, added lines, and removed lines | [Source](../examples/languages/diff.diff) |
+| Dockerfile | `dockerfile` | Build | `Dockerfile` | Instructions, modifiers, variables, strings, and comments | [Source](../examples/languages/dockerfile.Dockerfile) |
+| dotenv | `dotenv` | Configuration | `.env` | Assignments, `export`, references, values, and comments | [Source](../examples/languages/dotenv.env) |
+| Go | `go` | Programming | `.go` | Two-pass functions, methods, receivers, and type references | [Source](../examples/languages/go.go) |
+| HTML | `html` | Markup | `.html` | Tags, attributes, text, and embedded CSS or JavaScript | [Source](../examples/languages/html.html) |
+| HTTP | `http` | Protocol | `.http` | Request or response lines, headers, and body text | [Source](../examples/languages/http.http) |
+| INI | `ini` | Configuration | `.ini` | Sections, assignments, typed values, references, and comments | [Source](../examples/languages/ini.ini) |
+| Java | `java` | Programming | `.java` | Two-pass definitions, calls, types, and brace context | [Source](../examples/languages/java.java) |
+| JavaScript | `javascript` | Programming | `.js` | Declarations, calls, literals, templates, and regular expressions | [Source](../examples/languages/javascript.js) |
+| JSON | `json` | Data | `.json` | Object keys, strings, numbers, booleans, and null | [Source](../examples/languages/json.json) |
+| Makefile | `makefile` | Build | `.mk` | Targets, dependencies, recipes, variables, and directives | [Source](../examples/languages/makefile.mk) |
+| Markdown | `markdown` | Markup | `.md` | Blocks, inline markup, and registered fenced languages | [Source](../examples/languages/markdown.md) |
+| PHP | `php` | Programming | `.php` | Two-pass semantic definitions, calls, types, and variables | [Source](../examples/languages/php.php) |
+| Python | `python` | Programming | `.py` | Two-pass function and class definitions, calls, and references | [Source](../examples/languages/python.py) |
+| Ruby | `ruby` | Programming | `.rb` | Two-pass definitions, calls, constants, and variables | [Source](../examples/languages/ruby.rb) |
+| Rust | `rust` | Programming | `.rs` | Two-pass definitions, calls, types, lifetimes, and macros | [Source](../examples/languages/rust.rs) |
+| SCSS | `scss` | Stylesheet | `.scss` | CSS plus variables, nesting, mixins, and interpolation | [Source](../examples/languages/scss.scss) |
+| SQL | `sql` | Data | `.sql` | Keywords, functions, identifiers, literals, and comments | [Source](../examples/languages/sql.sql) |
+| SVG | `svg` | Markup | `.svg` | XML-style markup plus embedded CSS or JavaScript | [Source](../examples/languages/svg.svg) |
+| Swift | `swift` | Programming | `.swift` | Two-pass definitions, calls, types, attributes, and variables | [Source](../examples/languages/swift.swift) |
+| Twig | `twig` | Templating | `.twig` | Twig expressions and tags with embedded HTML by default | [Source](../examples/languages/twig.twig) |
+| TypeScript | `typescript` | Programming | `.ts` | JavaScript plus types, interfaces, enums, and decorators | [Source](../examples/languages/typescript.ts) |
+| XML | `xml` | Markup | `.xml` | Tags, attributes, processing instructions, and CDATA | [Source](../examples/languages/xml.xml) |
+| YAML | `yaml` | Data | `.yaml` | Mappings, sequences, anchors, aliases, values, and comments | [Source](../examples/languages/yaml.yaml) |
+
+The source files above are the canonical compact documentation examples.
+See [Examples](examples.md) for generated previews.
+
+## PHP snippets without an opening tag
+
+`php-snippet` is a convenience identifier handled by `Highlighter`; it is not
+a 28th registered language. It lets you highlight PHP fragments that omit the
+opening tag:
+
+```php
+$html = $highlighter->highlight(
+ '$total = array_sum($prices);',
+ 'php-snippet',
+);
+```
+
+The highlighter temporarily prepends `registerLanguage(new MyLanguage());
+```
+
+Registering an existing identifier replaces that parser on the highlighter
+instance. Theme authors style the generic semantic scopes emitted by parsers;
+see [Creating a theme](creating-a-theme.md).
diff --git a/docs/public-api.md b/docs/public-api.md
new file mode 100644
index 0000000..42d47bc
--- /dev/null
+++ b/docs/public-api.md
@@ -0,0 +1,65 @@
+# Public API
+
+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
+
+`Highlighter` is the primary facade. Its supported operations are:
+
+- construction with a `ThemeInterface`, optional embedding registry, and
+ optional language list;
+- `highlight()` for escaped HTML output;
+- `getTheme()` for the configured theme;
+- `registerLanguage()` for adding or replacing a parser;
+- `getEmbeddedRegistry()` for inspecting embedding plans;
+- `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.
+
+## Theme extension contract
+
+Custom themes implement `ThemeInterface`. The `Scope` enum and its string
+values form the semantic vocabulary supplied to themes. The built-in theme
+classes and the Highlight.js, Prism, and TextMate adapters are supported public
+implementations.
+
+See [Creating a theme](creating-a-theme.md) and
+[Theme adapters](theme-adapters.md) for complete examples.
+
+## Language extension contract
+
+Custom parsers implement `LanguageInterface` and return a `ParsedStream` made
+of `ParsedToken` values. `StreamBuilder`, `TokenType`, and `Scope` are supported
+building blocks for those parsers. `Languages::getDefaultLanguages()` returns
+the built-in registry.
+
+Embedded parsers use `EmbeddedLanguageCapable`, `EmbeddedLanguageContext`, and
+the types under `Alto\Code\Highlight\Embedded`. Their documented constructors
+and public methods are covered by the same 1.x compatibility promise.
+
+See [Languages](languages.md) and [Embedded languages](embedded-languages.md)
+for usage and behavior.
+
+## Exceptions
+
+`LanguageNotFoundException` reports an unknown language identifier.
+`ParseException` reports source that a semantic parser cannot process. Both are
+part of the supported exception contract.
+
+## Compatibility boundary
+
+The following details are not compatibility contracts:
+
+- concrete lexer, semantic parser, state, and token classes inside a built-in
+ language implementation;
+- exact whitespace inside generated HTML;
+- private methods and undocumented implementation details;
+- test fixtures, documentation tooling, and generated showcase assets.
+
+The generated element structure, documented CSS classes, source escaping,
+language identifiers, semantic scope values, and public signatures are covered
+by semantic versioning. Changes outside that boundary may occur in a minor or
+patch release when documented behavior remains intact.
diff --git a/docs/theme-adapters.md b/docs/theme-adapters.md
new file mode 100644
index 0000000..d145fa4
--- /dev/null
+++ b/docs/theme-adapters.md
@@ -0,0 +1,116 @@
+# Theme adapters
+
+Theme adapters reuse styles from Highlight.js, Prism, or TextMate while Alto
+continues to parse and render source code on the server. They do not load a
+client-side highlighter and do not add languages.
+
+Prefer local theme files in production. The `fromFile()` factories validate
+that a path exists, refers to a regular file, and is readable.
+
+## Highlight.js CSS
+
+```php
+'.$theme->getStylesheet().'';
+echo $highlighter->highlight('`
+element.
+
+## Prism CSS
+
+```php
+'.$theme->getStylesheet().'';
+echo $highlighter->highlight('const answer = 42;', 'javascript');
+```
+
+The adapter maps semantic scopes to Prism's `token` classes. Alto emits
+`language-*` classes on its `` and `` elements, so normal Prism
+theme selectors can apply without running Prism JavaScript.
+
+## TextMate `.tmTheme`
+
+```php
+';
+echo '.alto-highlight { padding: 1rem; overflow-x: auto; background: #272822; }';
+echo $theme->getStylesheet();
+echo '';
+echo $highlighter->highlight('SELECT * FROM users;', 'sql');
+```
+
+The adapter parses the PList XML, maps recognized TextMate scopes to Alto
+scopes, and generates `alto-tm-*` classes. TextMate use requires PHP's
+SimpleXML extension. Add your own `.alto-highlight` container rule when the
+generated stylesheet does not provide background, spacing, or overflow.
+
+## Dark-mode metadata
+
+The `isDark` argument records theme metadata returned by
+`ThemeInterface::isDark()`. It does not inspect the source file or alter its
+colors. Pass the value that matches the chosen theme.
+
+## Remote constructors
+
+`HighlightJsThemeAdapter` and `PrismThemeAdapter` constructors can fetch named
+themes from their configured CDN when no local CSS path is supplied. If a
+fetch fails, their stylesheet falls back to a CSS `@import`. This makes output
+dependent on network access, so `fromFile()` is the deterministic choice for
+production and documentation builds.
+
+## Styling boundaries
+
+Adapters translate style classes only:
+
+- parsing remains entirely in Alto;
+- supported language identifiers remain those in the active highlighter;
+- source escaping and output structure remain Alto's responsibility;
+- line numbers still use `alto-line-number` and `alto-highlighted`.
+
+See [Languages](languages.md), [Getting started](getting-started.md), and
+[Creating a theme](creating-a-theme.md) for those separate contracts.
diff --git a/docs/themes.md b/docs/themes.md
new file mode 100644
index 0000000..3373c02
--- /dev/null
+++ b/docs/themes.md
@@ -0,0 +1,85 @@
+# Themes
+
+Alto Code Highlight includes seven theme families and twelve selectable
+variants. A theme maps semantic scopes to CSS classes and provides the
+stylesheet for those classes.
+
+## Built-in variants
+
+| Family | Variant | Mode | Constructor |
+|---|---|---|---|
+| Alto | Alto Dark | Dark | `new AltoTheme()` |
+| Alto | Alto Light | Light | `new AltoTheme(dark: false)` |
+| Cupertino | Cupertino Dark | Dark | `new CupertinoTheme()` |
+| Cupertino | Cupertino Light | Light | `new CupertinoTheme(dark: false)` |
+| GitHub | GitHub Dark | Dark | `new GitHubTheme()` |
+| GitHub | GitHub Light | Light | `new GitHubTheme(dark: false)` |
+| Noctis | Noctis Dark | Dark | `new NoctisTheme()` |
+| Noctis | Noctis Light | Light | `new NoctisTheme(dark: false)` |
+| Solar | Solar Dark | Dark | `new SolarTheme(dark: true)` |
+| Solar | Solar Light | Light | `new SolarTheme()` |
+| Dracula | Dracula | Dark | `new DraculaTheme()` |
+| Polar | Polar | Dark | `new PolarTheme()` |
+
+Solar is the only dual-mode family whose constructor defaults to its light
+variant. `ThemeInterface::isDark()` reports the selected mode.
+
+## Select and render a theme
+
+```php
+use Alto\Code\Highlight\Highlighter;
+use Alto\Code\Highlight\Theme\GitHubTheme;
+
+$theme = new GitHubTheme(dark: false);
+$highlighter = new Highlighter($theme);
+
+$stylesheet = $theme->getStylesheet();
+$codeBlock = $highlighter->highlight($code, 'php');
+```
+
+Place `$stylesheet` once in the document `` or in an application CSS
+asset. Then render any number of blocks with `$codeBlock`. The stylesheet
+styles the shared `.alto-highlight` container and the theme's token classes.
+
+To switch themes, select a theme before rendering:
+
+```php
+use Alto\Code\Highlight\Theme\AltoTheme;
+
+$dark = 'dark' === $requestedMode;
+$theme = new AltoTheme(dark: $dark);
+$highlighter = new Highlighter($theme);
+```
+
+Use a corresponding stylesheet and highlighter together. HTML generated with
+one theme can contain different token class names from HTML generated with
+another.
+
+## Featured comparison
+
+The same PHP example rendered with the four primary documentation variants:
+
+| Alto Dark | Alto Light |
+|---|---|
+|  |  |
+
+| GitHub Dark | GitHub Light |
+|---|---|
+|  |  |
+
+The full PHP, Twig, HTML, JavaScript, and CSS matrix is available in
+[Examples](examples.md).
+
+## Line numbers and selected lines
+
+The highlighter emits structural `alto-line-number` and `alto-highlighted`
+classes when those options are enabled. Built-in theme stylesheets do not
+define their layout. Add application CSS for those classes as shown in
+[Getting started](getting-started.md#line-numbers-and-selected-lines).
+
+## Other theme sources
+
+- Use [theme adapters](theme-adapters.md) for local Highlight.js, Prism, or
+ TextMate theme files.
+- Follow [Creating a theme](creating-a-theme.md) to implement
+ `ThemeInterface` directly.
diff --git a/examples/catalog.php b/examples/catalog.php
new file mode 100644
index 0000000..739efd7
--- /dev/null
+++ b/examples/catalog.php
@@ -0,0 +1,258 @@
+ 'php',
+ 'name' => 'PHP',
+ 'category' => 'Programming',
+ 'extension' => 'php',
+ 'source' => 'languages/php.php',
+ 'featured' => true,
+ 'notes' => 'Definitions, calls, types, variables, and interpolated strings.',
+ ],
+ [
+ 'id' => 'html',
+ 'name' => 'HTML',
+ 'category' => 'Markup',
+ 'extension' => 'html',
+ 'source' => 'languages/html.html',
+ 'featured' => true,
+ 'notes' => 'Document structure, elements, attributes, and embedded content.',
+ ],
+ [
+ 'id' => 'svg',
+ 'name' => 'SVG',
+ 'category' => 'Markup',
+ 'extension' => 'svg',
+ 'source' => 'languages/svg.svg',
+ 'featured' => false,
+ 'notes' => 'XML-based vector elements, attributes, definitions, and colors.',
+ ],
+ [
+ 'id' => 'xml',
+ 'name' => 'XML',
+ 'category' => 'Markup',
+ 'extension' => 'xml',
+ 'source' => 'languages/xml.xml',
+ 'featured' => false,
+ 'notes' => 'Declarations, namespaces, nested elements, and attributes.',
+ ],
+ [
+ 'id' => 'yaml',
+ 'name' => 'YAML',
+ 'category' => 'Data',
+ 'extension' => 'yaml',
+ 'source' => 'languages/yaml.yaml',
+ 'featured' => false,
+ 'notes' => 'Mappings, sequences, booleans, flow collections, and strings.',
+ ],
+ [
+ 'id' => 'sql',
+ 'name' => 'SQL',
+ 'category' => 'Data',
+ 'extension' => 'sql',
+ 'source' => 'languages/sql.sql',
+ 'featured' => false,
+ 'notes' => 'Common table expressions, joins, functions, and ordering.',
+ ],
+ [
+ 'id' => 'json',
+ 'name' => 'JSON',
+ 'category' => 'Data',
+ 'extension' => 'json',
+ 'source' => 'languages/json.json',
+ 'featured' => false,
+ 'notes' => 'Objects, arrays, strings, booleans, and nested values.',
+ ],
+ [
+ 'id' => 'css',
+ 'name' => 'CSS',
+ 'category' => 'Stylesheet',
+ 'extension' => 'css',
+ 'source' => 'languages/css.css',
+ 'featured' => true,
+ 'notes' => 'Custom properties, selectors, functions, and layout declarations.',
+ ],
+ [
+ 'id' => 'scss',
+ 'name' => 'SCSS',
+ 'category' => 'Stylesheet',
+ 'extension' => 'scss',
+ 'source' => 'languages/scss.scss',
+ 'featured' => false,
+ 'notes' => 'Variables, nested selectors, functions, and media queries.',
+ ],
+ [
+ 'id' => 'markdown',
+ 'name' => 'Markdown',
+ 'category' => 'Markup',
+ 'extension' => 'md',
+ 'source' => 'languages/markdown.md',
+ 'featured' => false,
+ 'notes' => 'Headings, emphasis, inline code, lists, quotes, and fenced code.',
+ ],
+ [
+ 'id' => 'javascript',
+ 'name' => 'JavaScript',
+ 'category' => 'Programming',
+ 'extension' => 'js',
+ 'source' => 'languages/javascript.js',
+ 'featured' => true,
+ 'notes' => 'Destructuring, array methods, loops, templates, and exports.',
+ ],
+ [
+ 'id' => 'typescript',
+ 'name' => 'TypeScript',
+ 'category' => 'Programming',
+ 'extension' => 'ts',
+ 'source' => 'languages/typescript.ts',
+ 'featured' => false,
+ 'notes' => 'Interfaces, typed arrays, predicates, functions, and exports.',
+ ],
+ [
+ 'id' => 'twig',
+ 'name' => 'Twig',
+ 'category' => 'Templating',
+ 'extension' => 'twig',
+ 'source' => 'languages/twig.twig',
+ 'featured' => true,
+ 'notes' => 'Inheritance, blocks, filters, loops, output, and embedded HTML.',
+ ],
+ [
+ 'id' => 'makefile',
+ 'name' => 'Makefile',
+ 'category' => 'Build',
+ 'extension' => 'mk',
+ 'source' => 'languages/makefile.mk',
+ 'featured' => false,
+ 'notes' => 'Variables, functions, phony targets, prerequisites, and recipes.',
+ ],
+ [
+ 'id' => 'bash',
+ 'name' => 'Bash',
+ 'category' => 'Shell',
+ 'extension' => 'sh',
+ 'source' => 'languages/bash.sh',
+ 'featured' => false,
+ 'notes' => 'Shebangs, strict mode, arrays, loops, expansions, and redirects.',
+ ],
+ [
+ 'id' => 'ini',
+ 'name' => 'INI',
+ 'category' => 'Configuration',
+ 'extension' => 'ini',
+ 'source' => 'languages/ini.ini',
+ 'featured' => false,
+ 'notes' => 'Sections, keys, strings, booleans, numbers, and file paths.',
+ ],
+ [
+ 'id' => 'http',
+ 'name' => 'HTTP',
+ 'category' => 'Protocol',
+ 'extension' => 'http',
+ 'source' => 'languages/http.http',
+ 'featured' => false,
+ 'notes' => 'Request lines, headers, body separation, and JSON payloads.',
+ ],
+ [
+ 'id' => 'go',
+ 'name' => 'Go',
+ 'category' => 'Programming',
+ 'extension' => 'go',
+ 'source' => 'languages/go.go',
+ 'featured' => false,
+ 'notes' => 'Packages, imports, constants, structs, methods, and calls.',
+ ],
+ [
+ 'id' => 'rust',
+ 'name' => 'Rust',
+ 'category' => 'Programming',
+ 'extension' => 'rs',
+ 'source' => 'languages/rust.rs',
+ 'featured' => false,
+ 'notes' => 'Structs, references, options, inferred values, and macros.',
+ ],
+ [
+ 'id' => 'ruby',
+ 'name' => 'Ruby',
+ 'category' => 'Programming',
+ 'extension' => 'rb',
+ 'source' => 'languages/ruby.rb',
+ 'featured' => false,
+ 'notes' => 'Data classes, symbols, arrays, blocks, interpolation, and calls.',
+ ],
+ [
+ 'id' => 'swift',
+ 'name' => 'Swift',
+ 'category' => 'Programming',
+ 'extension' => 'swift',
+ 'source' => 'languages/swift.swift',
+ 'featured' => false,
+ 'notes' => 'Structs, typed properties, computed values, arrays, and loops.',
+ ],
+ [
+ 'id' => 'python',
+ 'name' => 'Python',
+ 'category' => 'Programming',
+ 'extension' => 'py',
+ 'source' => 'languages/python.py',
+ 'featured' => false,
+ 'notes' => 'Imports, decorators, data classes, annotations, loops, and f-strings.',
+ ],
+ [
+ 'id' => 'java',
+ 'name' => 'Java',
+ 'category' => 'Programming',
+ 'extension' => 'java',
+ 'source' => 'languages/java.java',
+ 'featured' => false,
+ 'notes' => 'Packages, records, classes, typed methods, streams, and references.',
+ ],
+ [
+ 'id' => 'csharp',
+ 'name' => 'C#',
+ 'category' => 'Programming',
+ 'extension' => 'cs',
+ 'source' => 'languages/csharp.cs',
+ 'featured' => false,
+ 'notes' => 'Usings, records, inferred arrays, LINQ, loops, and interpolation.',
+ ],
+ [
+ 'id' => 'dockerfile',
+ 'name' => 'Dockerfile',
+ 'category' => 'Build',
+ 'extension' => 'Dockerfile',
+ 'source' => 'languages/dockerfile.Dockerfile',
+ 'featured' => false,
+ 'notes' => 'Multi-stage builds, copies, work directories, runs, and commands.',
+ ],
+ [
+ 'id' => 'diff',
+ 'name' => 'Diff',
+ 'category' => 'Change',
+ 'extension' => 'diff',
+ 'source' => 'languages/diff.diff',
+ 'featured' => false,
+ 'notes' => 'Git headers, file markers, hunk ranges, context, and additions.',
+ ],
+ [
+ 'id' => 'dotenv',
+ 'name' => 'dotenv',
+ 'category' => 'Configuration',
+ 'extension' => 'env',
+ 'source' => 'languages/dotenv.env',
+ 'featured' => false,
+ 'notes' => 'Assignments, booleans, quoted values, URLs, and interpolation.',
+ ],
+];
diff --git a/examples/languages/bash.sh b/examples/languages/bash.sh
new file mode 100644
index 0000000..baa9990
--- /dev/null
+++ b/examples/languages/bash.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+readonly app="alto"
+files=(src/*.php)
+for file in "${files[@]}"; do
+ printf 'Checking %s\n' "$file"
+ php -l "$file" >/dev/null
+done
diff --git a/examples/languages/csharp.cs b/examples/languages/csharp.cs
new file mode 100644
index 0000000..e1b6d51
--- /dev/null
+++ b/examples/languages/csharp.cs
@@ -0,0 +1,8 @@
+using System;
+using System.Linq;
+var users = new[] { new User("Ada", true) };
+foreach (var user in users.Where(user => user.Active))
+{
+ Console.WriteLine($"Hello, {user.Name}!");
+}
+record User(string Name, bool Active);
diff --git a/examples/languages/css.css b/examples/languages/css.css
new file mode 100644
index 0000000..ba46cbd
--- /dev/null
+++ b/examples/languages/css.css
@@ -0,0 +1,8 @@
+:root {
+ --accent: #7c3aed;
+}
+.card:hover {
+ color: var(--accent);
+ display: grid;
+ gap: 0.75rem;
+}
diff --git a/examples/languages/diff.diff b/examples/languages/diff.diff
new file mode 100644
index 0000000..baa1925
--- /dev/null
+++ b/examples/languages/diff.diff
@@ -0,0 +1,8 @@
+diff --git a/src/Theme.php b/src/Theme.php
+index 1a2b3c4..5d6e7f8 100644
+--- a/src/Theme.php
++++ b/src/Theme.php
+@@ -1,2 +1,3 @@
+ final class Theme {}
++final class AltoTheme extends Theme {}
+ // End of file
diff --git a/examples/languages/dockerfile.Dockerfile b/examples/languages/dockerfile.Dockerfile
new file mode 100644
index 0000000..cf9ecaa
--- /dev/null
+++ b/examples/languages/dockerfile.Dockerfile
@@ -0,0 +1,8 @@
+FROM composer:2 AS vendor
+COPY composer.json composer.lock /app/
+RUN composer install -d /app --no-dev --no-interaction
+FROM php:8.4-cli-alpine
+WORKDIR /app
+COPY --from=vendor /app/vendor ./vendor
+COPY . .
+CMD ["php", "bin/highlight.php"]
diff --git a/examples/languages/dotenv.env b/examples/languages/dotenv.env
new file mode 100644
index 0000000..17d2df7
--- /dev/null
+++ b/examples/languages/dotenv.env
@@ -0,0 +1,8 @@
+APP_ENV=production
+APP_DEBUG=false
+APP_SECRET="change-me"
+DATABASE_URL=sqlite:///var/data.db
+CACHE_PREFIX=${APP_ENV}_alto
+MAILER_DSN=null://null
+FEATURE_PREVIEW=true
+EMPTY_VALUE=
diff --git a/examples/languages/go.go b/examples/languages/go.go
new file mode 100644
index 0000000..635d8cd
--- /dev/null
+++ b/examples/languages/go.go
@@ -0,0 +1,8 @@
+package main
+import "fmt"
+const app = "Alto"
+type User struct { Name string }
+func (u User) Greet() string {
+ return fmt.Sprintf("%s: Hello, %s!", app, u.Name)
+}
+func main() { fmt.Println(User{Name: "Ada"}.Greet()) }
diff --git a/examples/languages/html.html b/examples/languages/html.html
new file mode 100644
index 0000000..69db278
--- /dev/null
+++ b/examples/languages/html.html
@@ -0,0 +1,8 @@
+
+
+Profile
+
+
+ Hello, Alto
+
+
diff --git a/examples/languages/http.http b/examples/languages/http.http
new file mode 100644
index 0000000..21eaf93
--- /dev/null
+++ b/examples/languages/http.http
@@ -0,0 +1,8 @@
+POST /api/highlight HTTP/1.1
+Host: example.test
+Accept: application/json
+Content-Type: application/json
+X-Theme: alto-dark
+
+{"language":"php",
+ "code":"echo 42;"}
diff --git a/examples/languages/ini.ini b/examples/languages/ini.ini
new file mode 100644
index 0000000..1ab0c21
--- /dev/null
+++ b/examples/languages/ini.ini
@@ -0,0 +1,8 @@
+[app]
+name = Alto
+debug = false
+[database]
+driver = sqlite
+path = var/data.db
+timeout = 30
+readonly = true
diff --git a/examples/languages/java.java b/examples/languages/java.java
new file mode 100644
index 0000000..e9d99cf
--- /dev/null
+++ b/examples/languages/java.java
@@ -0,0 +1,8 @@
+package example;
+import java.util.List;
+record User(String name, boolean active) {}
+final class Greeting {
+ static List active(List users) {
+ return users.stream().filter(User::active).toList();
+ }
+}
diff --git a/examples/languages/javascript.js b/examples/languages/javascript.js
new file mode 100644
index 0000000..5bab382
--- /dev/null
+++ b/examples/languages/javascript.js
@@ -0,0 +1,8 @@
+const users = [{ name: 'Ada', active: true }];
+const names = users
+ .filter(({ active }) => active)
+ .map(({ name }) => name);
+for (const name of names) {
+ console.log(`Hello, ${name}!`);
+}
+export { names };
diff --git a/examples/languages/json.json b/examples/languages/json.json
new file mode 100644
index 0000000..23676e8
--- /dev/null
+++ b/examples/languages/json.json
@@ -0,0 +1,8 @@
+{
+ "name": "alto/code-highlight",
+ "private": false,
+ "languages": ["php", "twig", "javascript"],
+ "theme": {
+ "name": "Alto Dark"
+ }
+}
diff --git a/examples/languages/makefile.mk b/examples/languages/makefile.mk
new file mode 100644
index 0000000..b142b60
--- /dev/null
+++ b/examples/languages/makefile.mk
@@ -0,0 +1,8 @@
+APP := alto
+SOURCES := $(wildcard src/*.php)
+.PHONY: test build
+test:
+ @vendor/bin/phpunit
+build: $(SOURCES)
+ @echo "Building $(APP)"
+ @php tools/build.php
diff --git a/examples/languages/markdown.md b/examples/languages/markdown.md
new file mode 100644
index 0000000..b085192
--- /dev/null
+++ b/examples/languages/markdown.md
@@ -0,0 +1,8 @@
+# Code Highlight
+A **semantic** highlighter for `PHP`.
+- No third-party PHP packages
+- Context-aware parsing
+> Stable HTML output
+```php
+echo "Hello, Alto!";
+```
diff --git a/examples/languages/php.php b/examples/languages/php.php
new file mode 100644
index 0000000..37e4a0c
--- /dev/null
+++ b/examples/languages/php.php
@@ -0,0 +1,13 @@
+ str:
+ return f"Hello, {user.name}!"
+for user in [User("Ada"), User("Grace")]:
+ print(greet(user))
diff --git a/examples/languages/ruby.rb b/examples/languages/ruby.rb
new file mode 100644
index 0000000..5882df6
--- /dev/null
+++ b/examples/languages/ruby.rb
@@ -0,0 +1,8 @@
+User = Data.define(:name, :active)
+users = [
+ User.new(name: "Ada", active: true),
+ User.new(name: "Grace", active: false)
+]
+users.select(&:active).each do |user|
+ puts "Hello, #{user.name}!"
+end
diff --git a/examples/languages/rust.rs b/examples/languages/rust.rs
new file mode 100644
index 0000000..9274391
--- /dev/null
+++ b/examples/languages/rust.rs
@@ -0,0 +1,8 @@
+struct User { name: String, active: bool }
+fn active_name(user: &User) -> Option<&str> {
+ user.active.then_some(user.name.as_str())
+}
+fn main() {
+ let user = User { name: "Ada".into(), active: true };
+ println!("{:?}", active_name(&user));
+}
diff --git a/examples/languages/scss.scss b/examples/languages/scss.scss
new file mode 100644
index 0000000..29bf036
--- /dev/null
+++ b/examples/languages/scss.scss
@@ -0,0 +1,8 @@
+$accent: #7c3aed;
+.card {
+ color: $accent;
+ &:hover {
+ transform: translateY(-2px);
+ }
+ @media (min-width: 48rem) { display: grid; }
+}
diff --git a/examples/languages/sql.sql b/examples/languages/sql.sql
new file mode 100644
index 0000000..967521b
--- /dev/null
+++ b/examples/languages/sql.sql
@@ -0,0 +1,8 @@
+WITH active_users AS (
+ SELECT id, name
+ FROM users
+ WHERE active = TRUE
+)
+SELECT name, COUNT(*) AS projects
+FROM active_users JOIN projects USING (id)
+GROUP BY name ORDER BY projects DESC;
diff --git a/examples/languages/svg.svg b/examples/languages/svg.svg
new file mode 100644
index 0000000..42b4087
--- /dev/null
+++ b/examples/languages/svg.svg
@@ -0,0 +1,8 @@
+
diff --git a/examples/languages/swift.swift b/examples/languages/swift.swift
new file mode 100644
index 0000000..749579c
--- /dev/null
+++ b/examples/languages/swift.swift
@@ -0,0 +1,8 @@
+struct User {
+ let name: String
+ var greeting: String { "Hello, \(name)!" }
+}
+let users = [User(name: "Ada"), User(name: "Grace")]
+for user in users {
+ print(user.greeting)
+}
diff --git a/examples/languages/twig.twig b/examples/languages/twig.twig
new file mode 100644
index 0000000..b655795
--- /dev/null
+++ b/examples/languages/twig.twig
@@ -0,0 +1,8 @@
+{% extends 'base.html.twig' %}
+{% block body %}
+ {% set users = users|filter(user => user.active) %}
+
+ {% for user in users %}
+ - {{ user.name|upper }}
+ {% endfor %}
+
{% endblock %}
diff --git a/examples/languages/typescript.ts b/examples/languages/typescript.ts
new file mode 100644
index 0000000..07a3c01
--- /dev/null
+++ b/examples/languages/typescript.ts
@@ -0,0 +1,8 @@
+interface User { name: string; active: boolean }
+const users: User[] = [{ name: 'Ada', active: true }];
+const active = users.filter((user): boolean => user.active);
+function greet(user: User): string {
+ return `Hello, ${user.name}!`;
+}
+const message: string = greet(active[0]);
+export { active, message };
diff --git a/examples/languages/xml.xml b/examples/languages/xml.xml
new file mode 100644
index 0000000..779c499
--- /dev/null
+++ b/examples/languages/xml.xml
@@ -0,0 +1,8 @@
+
+
+
+ Code Highlight
+ Alto Team
+ PHP
+
+
diff --git a/examples/languages/yaml.yaml b/examples/languages/yaml.yaml
new file mode 100644
index 0000000..e24334f
--- /dev/null
+++ b/examples/languages/yaml.yaml
@@ -0,0 +1,8 @@
+app:
+ name: Alto
+ debug: false
+ languages:
+ - php
+ - twig
+ cache: {driver: redis, ttl: 3600}
+ greeting: "Hello, syntax!"
diff --git a/tests/Unit/ExamplesCatalogTest.php b/tests/Unit/ExamplesCatalogTest.php
new file mode 100644
index 0000000..d0e0844
--- /dev/null
+++ b/tests/Unit/ExamplesCatalogTest.php
@@ -0,0 +1,140 @@
+ $language->getIdentifier(),
+ Languages::getDefaultLanguages(),
+ );
+ $catalogIdentifiers = array_column(self::catalog(), 'id');
+
+ sort($registryIdentifiers);
+ sort($catalogIdentifiers);
+
+ self::assertSame($registryIdentifiers, $catalogIdentifiers);
+ }
+
+ public function testCatalogHasUniqueIdentifiersAndExactSchema(): void
+ {
+ $catalog = self::catalog();
+ $identifiers = array_column($catalog, 'id');
+
+ self::assertCount(count(array_unique($identifiers)), $identifiers);
+
+ foreach ($catalog as $entry) {
+ self::assertSame(
+ ['id', 'name', 'category', 'extension', 'source', 'featured', 'notes'],
+ array_keys($entry),
+ sprintf('Unexpected catalog schema for "%s".', $entry['id']),
+ );
+ }
+ }
+
+ public function testOnlyExpectedLanguagesAreFeatured(): void
+ {
+ $featured = array_column(
+ array_filter(self::catalog(), static fn (array $entry): bool => $entry['featured']),
+ 'id',
+ );
+
+ sort($featured);
+
+ self::assertSame(['css', 'html', 'javascript', 'php', 'twig'], $featured);
+ }
+
+ /**
+ * @param array{
+ * id: string,
+ * name: string,
+ * category: string,
+ * extension: string,
+ * source: string,
+ * featured: bool,
+ * notes: string
+ * } $entry
+ */
+ #[DataProvider('catalogProvider')]
+ public function testExampleIsReadableFitsShowcaseAndRoundTripsThroughHighlighter(array $entry): void
+ {
+ $path = dirname(self::CATALOG_PATH).'/'.$entry['source'];
+
+ self::assertFileExists($path);
+ self::assertIsReadable($path);
+
+ $source = file_get_contents($path);
+ self::assertNotFalse($source);
+ $source = rtrim($source, "\n");
+ $lineCount = count(explode("\n", $source));
+ self::assertGreaterThanOrEqual(8, $lineCount);
+ self::assertLessThanOrEqual(13, $lineCount);
+
+ $html = (new Highlighter(new AltoTheme()))->highlight($source, $entry['id']);
+ $reconstructed = html_entity_decode(
+ strip_tags($html),
+ ENT_QUOTES | ENT_HTML5,
+ 'UTF-8',
+ );
+
+ self::assertSame($source, $reconstructed);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function catalogProvider(): iterable
+ {
+ foreach (self::catalog() as $entry) {
+ yield $entry['id'] => [$entry];
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private static function catalog(): array
+ {
+ return require self::CATALOG_PATH;
+ }
+}
diff --git a/tools/docs-showcase/.gitignore b/tools/docs-showcase/.gitignore
new file mode 100644
index 0000000..283d9a4
--- /dev/null
+++ b/tools/docs-showcase/.gitignore
@@ -0,0 +1,3 @@
+/build/
+/node_modules/
+/vendor/
diff --git a/tools/docs-showcase/README.md b/tools/docs-showcase/README.md
new file mode 100644
index 0000000..cff6d29
--- /dev/null
+++ b/tools/docs-showcase/README.md
@@ -0,0 +1,69 @@
+# Documentation showcase
+
+This local-only tool renders Alto's canonical compact examples as
+deterministic 800 × 400 documentation cards. It uses the package through a
+Composer path repository and the real `Highlighter` API. Generated HTML is
+written to `build/`; reviewed PNG files are written to
+`../../docs/assets/examples/`.
+
+## Setup
+
+```bash
+composer install
+npm install
+npx playwright install chromium
+```
+
+The generated cards contain the theme CSS, highlighted HTML, metadata, source,
+and a locally installed JetBrains Mono font. They make no network requests.
+
+## Commands
+
+Generate, capture, and validate the five featured languages in Alto and GitHub
+dark/light variants:
+
+```bash
+npm run refresh
+```
+
+Filter a generation or capture:
+
+```bash
+npm run generate -- --language=php
+npm run generate -- --theme=dracula
+npm run generate -- --language=twig --theme=github-light
+npm run capture -- --language=twig --theme=github-light
+```
+
+Generate and capture every cataloged language/theme combination:
+
+```bash
+npm run generate -- --all
+npm run capture -- --all
+npm run verify -- --all
+```
+
+The complete matrix is an inspection artifact and stays under
+`build/screenshots/`. It is not published to `docs/` unless an explicit
+`--output-dir` is passed. This also applies when the current manifest was
+generated with `--all` and capture is run without filters.
+
+`--all` cannot be combined with a language or theme filter. Run any command
+with `--help` for its exact interface. Filters are stable identifiers, not
+display names.
+
+## Inputs and outputs
+
+- `../../examples/catalog.php` is the language catalog and points to the source
+ files relative to `../../examples/`.
+- `build/cards//.html` contains standalone card documents.
+- `build/gallery.html` presents the current generated selection.
+- `build/manifest.json` is the machine-readable generation contract.
+- `build/captures.json` records the last captured selection.
+- `../../docs/assets/examples//.png` contains captured cards.
+
+Generation fails when a catalog entry is missing, duplicated, unknown to Alto,
+unreadable, or outside the 8-13 visible-line budget. Capture additionally
+checks source reconstruction, font readiness, dimensions, and overflow before
+writing an image. Verification reads the PNG header and requires every captured
+image to be exactly 800 × 400.
diff --git a/tools/docs-showcase/bin/capture.mjs b/tools/docs-showcase/bin/capture.mjs
new file mode 100644
index 0000000..e59edea
--- /dev/null
+++ b/tools/docs-showcase/bin/capture.mjs
@@ -0,0 +1,332 @@
+#!/usr/bin/env node
+
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { chromium } from 'playwright';
+
+const CARD_WIDTH = 800;
+const CARD_HEIGHT = 400;
+const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const projectRoot = path.resolve(toolRoot, '..', '..');
+const buildRoot = path.join(toolRoot, 'build');
+
+try {
+ const options = parseOptions(process.argv.slice(2));
+
+ if (options.help) {
+ printHelp();
+ process.exit(0);
+ }
+
+ const manifest = await readJson(path.join(buildRoot, 'manifest.json'));
+ const cards = selectCards(manifest, options);
+ const outputRoot = options.outputDir
+ ? path.resolve(process.cwd(), options.outputDir)
+ : options.all || manifest.selection === 'all'
+ ? path.join(buildRoot, 'screenshots')
+ : path.join(projectRoot, 'docs', 'assets', 'examples');
+
+ if (options.all && manifest.selection !== 'all') {
+ throw new Error('The build manifest is not complete. Run "npm run generate -- --all" first.');
+ }
+
+ await mkdir(outputRoot, { recursive: true });
+
+ const browser = await chromium.launch({ headless: true });
+ const captured = [];
+
+ try {
+ const context = await browser.newContext({
+ viewport: { width: CARD_WIDTH, height: CARD_HEIGHT },
+ deviceScaleFactor: 1,
+ colorScheme: 'dark',
+ });
+
+ for (const [index, card] of cards.entries()) {
+ const page = await context.newPage();
+ const remoteRequests = [];
+ page.on('request', (request) => {
+ if (/^https?:/u.test(request.url())) {
+ remoteRequests.push(request.url());
+ }
+ });
+
+ const htmlPath = path.join(buildRoot, card.html);
+ await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'load' });
+ await page.evaluate(() => document.fonts.ready);
+
+ const validation = await validateCard(page);
+ if (remoteRequests.length > 0) {
+ throw new Error(`${card.theme.id}/${card.language.id}: remote request attempted: ${remoteRequests[0]}`);
+ }
+ assertCard(card, validation);
+
+ const outputPath = path.join(outputRoot, card.theme.id, `${card.language.id}.png`);
+ await mkdir(path.dirname(outputPath), { recursive: true });
+ await page.locator('#showcase-card').screenshot({
+ path: outputPath,
+ type: 'png',
+ animations: 'disabled',
+ });
+ await page.close();
+
+ captured.push({
+ language: card.language.id,
+ theme: card.theme.id,
+ sourceSha256: card.sourceSha256,
+ html: card.html,
+ png: path.relative(toolRoot, outputPath).split(path.sep).join('/'),
+ });
+ process.stdout.write(
+ `[${String(index + 1).padStart(String(cards.length).length, ' ')}/${cards.length}] ` +
+ `${card.theme.id}/${card.language.id}.png\n`,
+ );
+ }
+ } finally {
+ await browser.close();
+ }
+
+ await writeJson(path.join(buildRoot, 'captures.json'), {
+ schemaVersion: 1,
+ capturedAt: new Date().toISOString(),
+ outputRoot: path.relative(toolRoot, outputRoot).split(path.sep).join('/'),
+ expectedCount: captured.length,
+ captures: captured,
+ });
+
+ process.stdout.write(`Captured ${captured.length} card${captured.length === 1 ? '' : 's'} at 800x400.\n`);
+} catch (error) {
+ process.stderr.write(`Error: ${formatError(error)}\n`);
+ process.exitCode = isUsageError(error) ? 2 : 1;
+}
+
+function parseOptions(arguments_) {
+ const options = {
+ language: null,
+ theme: null,
+ all: false,
+ outputDir: null,
+ help: false,
+ };
+
+ for (const argument of arguments_) {
+ if (argument === '-h' || argument === '--help') {
+ options.help = true;
+ } else if (argument === '--all') {
+ options.all = true;
+ } else if (argument.startsWith('--language=')) {
+ options.language = optionValue(argument, '--language=');
+ } else if (argument.startsWith('--theme=')) {
+ options.theme = optionValue(argument, '--theme=');
+ } else if (argument.startsWith('--output-dir=')) {
+ options.outputDir = optionValue(argument, '--output-dir=');
+ } else {
+ throw usageError(`Unknown argument "${argument}".`);
+ }
+ }
+
+ if (options.all && (options.language || options.theme)) {
+ throw usageError('--all cannot be combined with --language or --theme.');
+ }
+
+ return options;
+}
+
+function optionValue(argument, prefix) {
+ const value = argument.slice(prefix.length).trim();
+ if (!value) {
+ throw usageError(`${prefix.slice(0, -1)} requires a non-empty value.`);
+ }
+
+ return value;
+}
+
+function printHelp() {
+ process.stdout.write(`Capture generated Alto documentation cards.
+
+Usage:
+ node bin/capture.mjs [--language=] [--theme=] [--output-dir=]
+ node bin/capture.mjs --all [--output-dir=]
+
+Options:
+ --language= Capture one language from the current build manifest.
+ --theme= Capture one theme from the current build manifest.
+ --all Require and capture a complete generated matrix.
+ --output-dir= Override the default output directory.
+ -h, --help Show this help.
+
+With no filters, the command captures every card in build/manifest.json.
+The default and filtered matrices publish to ../../docs/assets/examples.
+A complete --all matrix stays local under build/screenshots unless overridden.
+`);
+}
+
+function selectCards(manifest, options) {
+ assertManifest(manifest);
+
+ if (options.language && !manifest.catalog.languageIds.includes(options.language)) {
+ throw usageError(`Unknown language "${options.language}".`);
+ }
+ if (options.theme && !manifest.catalog.themeIds.includes(options.theme)) {
+ throw usageError(`Unknown theme "${options.theme}".`);
+ }
+
+ const cards = manifest.cards.filter((card) =>
+ (!options.language || card.language.id === options.language) &&
+ (!options.theme || card.theme.id === options.theme)
+ );
+
+ if (cards.length === 0) {
+ throw new Error('No generated cards match the filters. Regenerate the requested selection first.');
+ }
+
+ return cards;
+}
+
+function assertManifest(manifest) {
+ if (!manifest || manifest.schemaVersion !== 1 || !Array.isArray(manifest.cards)) {
+ throw new Error('build/manifest.json has an unsupported format. Run the generator again.');
+ }
+ if (!manifest.catalog || !Array.isArray(manifest.catalog.languageIds) || !Array.isArray(manifest.catalog.themeIds)) {
+ throw new Error('build/manifest.json is missing catalog metadata.');
+ }
+}
+
+async function validateCard(page) {
+ return page.evaluate(async ({ width, height }) => {
+ await document.fonts.ready;
+
+ const card = document.querySelector('#showcase-card');
+ const pre = card?.querySelector('.alto-highlight');
+ const code = pre?.querySelector('code');
+ const header = card?.querySelector('.showcase-header');
+ const sourceElement = document.querySelector('#showcase-source');
+
+ if (!card || !pre || !code || !header || !sourceElement) {
+ return { error: 'required card elements are missing' };
+ }
+
+ const source = JSON.parse(sourceElement.textContent ?? '""');
+ const normalizedSource = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
+ const withoutFinalNewline = normalizedSource.endsWith('\n')
+ ? normalizedSource.slice(0, -1)
+ : normalizedSource;
+ const lineCount = withoutFinalNewline === '' ? 0 : withoutFinalNewline.split('\n').length;
+ const cardRect = card.getBoundingClientRect();
+ const preRect = pre.getBoundingClientRect();
+ const codeRect = code.getBoundingClientRect();
+ const headerRect = header.getBoundingClientRect();
+ const computedCode = getComputedStyle(code);
+ const embeddedFont = document.body.dataset.fontEmbedded === 'true';
+
+ return {
+ viewport: {
+ width: window.innerWidth,
+ height: window.innerHeight,
+ documentWidth: document.documentElement.scrollWidth,
+ documentHeight: document.documentElement.scrollHeight,
+ },
+ card: {
+ width: cardRect.width,
+ height: cardRect.height,
+ },
+ pre: {
+ width: preRect.width,
+ height: preRect.height,
+ clientWidth: pre.clientWidth,
+ clientHeight: pre.clientHeight,
+ scrollWidth: pre.scrollWidth,
+ scrollHeight: pre.scrollHeight,
+ },
+ contentInsideCard:
+ codeRect.left >= cardRect.left &&
+ codeRect.right <= cardRect.right &&
+ codeRect.top > headerRect.bottom &&
+ codeRect.bottom <= cardRect.bottom,
+ lineCount,
+ sourceMatches: code.textContent === source,
+ whiteSpace: computedCode.whiteSpace,
+ fontFamily: computedCode.fontFamily,
+ fontsReady: document.fonts.status === 'loaded',
+ embeddedFont,
+ embeddedFontReady: !embeddedFont || document.fonts.check('19px "Showcase Mono"'),
+ expected: { width, height },
+ };
+ }, { width: CARD_WIDTH, height: CARD_HEIGHT });
+}
+
+function assertCard(card, validation) {
+ const prefix = `${card.theme.id}/${card.language.id}`;
+ if (validation.error) {
+ throw new Error(`${prefix}: ${validation.error}.`);
+ }
+ if (validation.viewport.width !== CARD_WIDTH || validation.viewport.height !== CARD_HEIGHT) {
+ throw new Error(`${prefix}: viewport is not ${CARD_WIDTH}x${CARD_HEIGHT}.`);
+ }
+ if (validation.card.width !== CARD_WIDTH || validation.card.height !== CARD_HEIGHT) {
+ throw new Error(`${prefix}: card is not ${CARD_WIDTH}x${CARD_HEIGHT}.`);
+ }
+ if (
+ validation.viewport.documentWidth > CARD_WIDTH ||
+ validation.viewport.documentHeight > CARD_HEIGHT
+ ) {
+ throw new Error(`${prefix}: document overflows the viewport.`);
+ }
+ if (
+ validation.pre.scrollWidth > validation.pre.clientWidth ||
+ validation.pre.scrollHeight > validation.pre.clientHeight ||
+ !validation.contentInsideCard
+ ) {
+ throw new Error(`${prefix}: highlighted code overflows its card.`);
+ }
+ if (
+ validation.lineCount !== card.lineCount ||
+ card.lineCount < 8 ||
+ card.lineCount > 13
+ ) {
+ throw new Error(`${prefix}: source must contain between 8 and 13 visible lines.`);
+ }
+ if (!validation.sourceMatches) {
+ throw new Error(`${prefix}: highlighted text does not reconstruct the source.`);
+ }
+ if (validation.whiteSpace !== 'pre') {
+ throw new Error(`${prefix}: highlighted source is allowed to wrap.`);
+ }
+ if (!validation.fontsReady || !validation.embeddedFontReady) {
+ throw new Error(`${prefix}: local fonts are not ready.`);
+ }
+ if (validation.embeddedFont && !validation.fontFamily.includes('Showcase Mono')) {
+ throw new Error(`${prefix}: highlighted code is not using the embedded font.`);
+ }
+}
+
+async function readJson(filePath) {
+ try {
+ return JSON.parse(await readFile(filePath, 'utf8'));
+ } catch (error) {
+ if (error && error.code === 'ENOENT') {
+ throw new Error(`Missing ${path.relative(process.cwd(), filePath)}. Run "npm run generate" first.`);
+ }
+ throw error;
+ }
+}
+
+async function writeJson(filePath, value) {
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+function usageError(message) {
+ const error = new Error(message);
+ error.usage = true;
+ return error;
+}
+
+function isUsageError(error) {
+ return Boolean(error && error.usage);
+}
+
+function formatError(error) {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/tools/docs-showcase/bin/generate.php b/tools/docs-showcase/bin/generate.php
new file mode 100644
index 0000000..59a974a
--- /dev/null
+++ b/tools/docs-showcase/bin/generate.php
@@ -0,0 +1,623 @@
+#!/usr/bin/env php
+highlight($source, $language['id']);
+ $relativeHtml = 'cards/'.$themeDefinition['id'].'/'.$language['id'].'.html';
+ $absoluteHtml = $buildRoot.'/'.$relativeHtml;
+
+ ensureDirectory(dirname($absoluteHtml));
+
+ $html = renderTemplate($toolRoot.'/templates/card.php', [
+ 'language' => $language,
+ 'theme' => [
+ 'id' => $themeDefinition['id'],
+ 'name' => $themeDefinition['name'],
+ 'dark' => $theme->isDark(),
+ ],
+ 'themeStylesheet' => $theme->getStylesheet(),
+ 'highlighted' => $highlighted,
+ 'source' => $source,
+ 'sourceHash' => hash('sha256', $source),
+ 'lineCount' => $lineCount,
+ 'fontCss' => $font['css'],
+ 'fontEmbedded' => $font['embedded'],
+ ]);
+ writeFile($absoluteHtml, $html);
+
+ $cards[] = [
+ 'language' => [
+ 'id' => $language['id'],
+ 'name' => $language['name'],
+ 'featured' => $language['featured'],
+ ],
+ 'theme' => [
+ 'id' => $themeDefinition['id'],
+ 'name' => $themeDefinition['name'],
+ 'dark' => $theme->isDark(),
+ ],
+ 'source' => '../../examples/'.$language['source'],
+ 'sourceSha256' => hash('sha256', $source),
+ 'lineCount' => $lineCount,
+ 'html' => $relativeHtml,
+ ];
+ }
+
+ $galleryCards = array_map(
+ static fn (array $card): array => [
+ 'language' => $card['language'],
+ 'theme' => $card['theme'],
+ 'relativeHtml' => $card['html'],
+ ],
+ $cards
+ );
+ writeFile(
+ $buildRoot.'/gallery.html',
+ renderTemplate($toolRoot.'/templates/gallery.php', ['cards' => $galleryCards])
+ );
+
+ $featuredLanguageIds = array_values(array_map(
+ static fn (array $language): string => $language['id'],
+ array_filter($languages, static fn (array $language): bool => $language['featured'])
+ ));
+ $themeIds = array_keys($themes);
+ $languageIds = array_column($languages, 'id');
+ $selectionName = selectionName($options);
+ $manifest = [
+ 'schemaVersion' => 1,
+ 'generatedAt' => gmdate('c'),
+ 'selection' => $selectionName,
+ 'fontEmbedded' => $font['embedded'],
+ 'catalog' => [
+ 'path' => '../../examples/catalog.php',
+ 'languageIds' => $languageIds,
+ 'featuredLanguageIds' => $featuredLanguageIds,
+ 'themeIds' => $themeIds,
+ 'defaultThemeIds' => defaultThemeIds(),
+ ],
+ 'filters' => [
+ 'language' => $options['language'],
+ 'theme' => $options['theme'],
+ 'all' => $options['all'],
+ ],
+ 'expectedCount' => count($cards),
+ 'cards' => $cards,
+ ];
+ writeJson($buildRoot.'/manifest.json', $manifest);
+
+ $fontNote = $font['embedded'] ? 'embedded font' : 'system font fallback';
+ fwrite(STDOUT, sprintf(
+ "Generated %d card%s and build/gallery.html (%s).\n",
+ count($cards),
+ 1 === count($cards) ? '' : 's',
+ $fontNote
+ ));
+} catch (InvalidArgumentException $exception) {
+ fwrite(STDERR, 'Error: '.$exception->getMessage()."\n\n");
+ fwrite(STDERR, "Run \"php bin/generate.php --help\" for usage.\n");
+ exit(EXIT_USAGE);
+} catch (Throwable $exception) {
+ fwrite(STDERR, 'Error: '.$exception->getMessage()."\n");
+ exit(1);
+}
+
+/**
+ * @param list $arguments
+ *
+ * @return array{language: ?string, theme: ?string, all: bool, help: bool}
+ */
+function parseOptions(array $arguments): array
+{
+ $options = [
+ 'language' => null,
+ 'theme' => null,
+ 'all' => false,
+ 'help' => false,
+ ];
+
+ foreach ($arguments as $argument) {
+ if ('-h' === $argument || '--help' === $argument) {
+ $options['help'] = true;
+ continue;
+ }
+
+ if ('--all' === $argument) {
+ $options['all'] = true;
+ continue;
+ }
+
+ if (str_starts_with($argument, '--language=')) {
+ $options['language'] = optionValue($argument, '--language=');
+ continue;
+ }
+
+ if (str_starts_with($argument, '--theme=')) {
+ $options['theme'] = optionValue($argument, '--theme=');
+ continue;
+ }
+
+ throw new InvalidArgumentException(sprintf('Unknown argument "%s".', $argument));
+ }
+
+ if ($options['all'] && (null !== $options['language'] || null !== $options['theme'])) {
+ throw new InvalidArgumentException('--all cannot be combined with --language or --theme.');
+ }
+
+ return $options;
+}
+
+function optionValue(string $argument, string $prefix): string
+{
+ $value = trim(substr($argument, strlen($prefix)));
+ if ('' === $value) {
+ throw new InvalidArgumentException(sprintf('%s requires a non-empty value.', rtrim($prefix, '=')));
+ }
+
+ return $value;
+}
+
+function printHelp(): void
+{
+ fwrite(STDOUT, <<<'HELP'
+Generate standalone Alto documentation cards.
+
+Usage:
+ php bin/generate.php [--language=] [--theme=]
+ php bin/generate.php --all
+
+Options:
+ --language= Generate one language across the default four themes.
+ --theme= Generate featured languages with one theme.
+ --all Generate every cataloged language/theme pair.
+ -h, --help Show this help.
+
+With no filters, the command generates featured languages in Alto Dark,
+Alto Light, GitHub Dark, and GitHub Light.
+
+HELP);
+}
+
+/**
+ * @return array
+ */
+function themeCatalog(): array
+{
+ return [
+ 'alto-dark' => [
+ 'id' => 'alto-dark',
+ 'name' => 'Alto Dark',
+ 'factory' => static fn (): ThemeInterface => new AltoTheme(),
+ ],
+ 'alto-light' => [
+ 'id' => 'alto-light',
+ 'name' => 'Alto Light',
+ 'factory' => static fn (): ThemeInterface => new AltoTheme(dark: false),
+ ],
+ 'cupertino-dark' => [
+ 'id' => 'cupertino-dark',
+ 'name' => 'Cupertino Dark',
+ 'factory' => static fn (): ThemeInterface => new CupertinoTheme(),
+ ],
+ 'cupertino-light' => [
+ 'id' => 'cupertino-light',
+ 'name' => 'Cupertino Light',
+ 'factory' => static fn (): ThemeInterface => new CupertinoTheme(dark: false),
+ ],
+ 'github-dark' => [
+ 'id' => 'github-dark',
+ 'name' => 'GitHub Dark',
+ 'factory' => static fn (): ThemeInterface => new GitHubTheme(),
+ ],
+ 'github-light' => [
+ 'id' => 'github-light',
+ 'name' => 'GitHub Light',
+ 'factory' => static fn (): ThemeInterface => new GitHubTheme(dark: false),
+ ],
+ 'noctis-dark' => [
+ 'id' => 'noctis-dark',
+ 'name' => 'Noctis Dark',
+ 'factory' => static fn (): ThemeInterface => new NoctisTheme(),
+ ],
+ 'noctis-light' => [
+ 'id' => 'noctis-light',
+ 'name' => 'Noctis Light',
+ 'factory' => static fn (): ThemeInterface => new NoctisTheme(dark: false),
+ ],
+ 'solar-light' => [
+ 'id' => 'solar-light',
+ 'name' => 'Solar Light',
+ 'factory' => static fn (): ThemeInterface => new SolarTheme(),
+ ],
+ 'solar-dark' => [
+ 'id' => 'solar-dark',
+ 'name' => 'Solar Dark',
+ 'factory' => static fn (): ThemeInterface => new SolarTheme(dark: true),
+ ],
+ 'dracula' => [
+ 'id' => 'dracula',
+ 'name' => 'Dracula',
+ 'factory' => static fn (): ThemeInterface => new DraculaTheme(),
+ ],
+ 'polar' => [
+ 'id' => 'polar',
+ 'name' => 'Polar',
+ 'factory' => static fn (): ThemeInterface => new PolarTheme(),
+ ],
+ ];
+}
+
+/**
+ * @return list
+ */
+function loadLanguageCatalog(string $catalogPath, string $examplesRoot): array
+{
+ if (!is_file($catalogPath)) {
+ throw new RuntimeException(sprintf(
+ 'Language catalog not found at %s. Create examples/catalog.php before generating cards.',
+ $catalogPath
+ ));
+ }
+
+ $catalog = require $catalogPath;
+ if (!is_array($catalog) || !array_is_list($catalog)) {
+ throw new RuntimeException('examples/catalog.php must return a list of language entries.');
+ }
+
+ $requiredKeys = ['id', 'name', 'category', 'extension', 'source', 'featured', 'notes'];
+ $languages = [];
+ $seen = [];
+
+ foreach ($catalog as $index => $entry) {
+ if (!is_array($entry)) {
+ throw new RuntimeException(sprintf('Catalog entry %d must be an array.', $index));
+ }
+
+ foreach ($requiredKeys as $key) {
+ if (!array_key_exists($key, $entry)) {
+ throw new RuntimeException(sprintf('Catalog entry %d is missing "%s".', $index, $key));
+ }
+ }
+
+ foreach (['id', 'name', 'category', 'extension', 'source', 'notes'] as $key) {
+ if (!is_string($entry[$key]) || '' === trim($entry[$key])) {
+ throw new RuntimeException(sprintf('Catalog entry %d has an invalid "%s".', $index, $key));
+ }
+ }
+
+ if (!is_bool($entry['featured'])) {
+ throw new RuntimeException(sprintf('Catalog entry %d has a non-boolean "featured" value.', $index));
+ }
+
+ $id = strtolower($entry['id']);
+ if ($id !== $entry['id'] || 1 !== preg_match('/^[a-z][a-z0-9-]*$/', $id)) {
+ throw new RuntimeException(sprintf('Catalog identifier "%s" is not a stable lowercase identifier.', $entry['id']));
+ }
+
+ if (isset($seen[$id])) {
+ throw new RuntimeException(sprintf('Catalog identifier "%s" is duplicated.', $id));
+ }
+ $seen[$id] = true;
+
+ $sourcePath = resolveSourcePath($examplesRoot, $entry['source']);
+ $source = sourceForDisplay(readRequiredFile($sourcePath));
+ $lineCount = visibleLineCount($source);
+ if ($lineCount < 8 || $lineCount > 13) {
+ throw new RuntimeException(sprintf(
+ 'Example "%s" must contain between 8 and 13 visible lines; found %d.',
+ $id,
+ $lineCount
+ ));
+ }
+
+ /** @var array{id: string, name: string, category: string, extension: string, source: string, featured: bool, notes: string} $entry */
+ $languages[] = $entry;
+ }
+
+ return $languages;
+}
+
+function resolveSourcePath(string $examplesRoot, string $relativePath): string
+{
+ if (str_starts_with($relativePath, '/') || str_contains($relativePath, "\0")) {
+ throw new RuntimeException(sprintf('Example source path "%s" must be relative.', $relativePath));
+ }
+
+ $sourcePath = realpath($examplesRoot.'/'.$relativePath);
+ $realExamplesRoot = realpath($examplesRoot);
+ if (false === $sourcePath || false === $realExamplesRoot) {
+ throw new RuntimeException(sprintf('Example source "%s" is not readable.', $relativePath));
+ }
+
+ if (!str_starts_with($sourcePath, $realExamplesRoot.DIRECTORY_SEPARATOR)) {
+ throw new RuntimeException(sprintf('Example source "%s" escapes the examples directory.', $relativePath));
+ }
+
+ return $sourcePath;
+}
+
+/**
+ * @param list $catalog
+ */
+function validateLanguageCatalog(array $catalog): void
+{
+ $registeredIds = array_map(
+ static fn ($language): string => $language->getIdentifier(),
+ Languages::getDefaultLanguages()
+ );
+ sort($registeredIds);
+
+ $catalogIds = array_column($catalog, 'id');
+ sort($catalogIds);
+
+ $missing = array_values(array_diff($registeredIds, $catalogIds));
+ $unknown = array_values(array_diff($catalogIds, $registeredIds));
+ if ([] !== $missing || [] !== $unknown) {
+ $details = [];
+ if ([] !== $missing) {
+ $details[] = 'missing: '.implode(', ', $missing);
+ }
+ if ([] !== $unknown) {
+ $details[] = 'unknown: '.implode(', ', $unknown);
+ }
+
+ throw new RuntimeException('Language catalog does not match Alto: '.implode('; ', $details).'.');
+ }
+}
+
+/**
+ * @param array{language: ?string, theme: ?string, all: bool, help: bool} $options
+ * @param list $languages
+ * @param array $themes
+ */
+function validateFilters(array $options, array $languages, array $themes): void
+{
+ $languageIds = array_column($languages, 'id');
+ if (null !== $options['language'] && !in_array($options['language'], $languageIds, true)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unknown language "%s". Available: %s.',
+ $options['language'],
+ implode(', ', $languageIds)
+ ));
+ }
+
+ if (null !== $options['theme'] && !isset($themes[$options['theme']])) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unknown theme "%s". Available: %s.',
+ $options['theme'],
+ implode(', ', array_keys($themes))
+ ));
+ }
+}
+
+/**
+ * @param list $languages
+ * @param array $themes
+ * @param array{language: ?string, theme: ?string, all: bool, help: bool} $options
+ *
+ * @return list, 1: array}>
+ */
+function selectCards(array $languages, array $themes, array $options): array
+{
+ $selectedLanguages = $languages;
+ $selectedThemes = $themes;
+
+ if (!$options['all']) {
+ if (null !== $options['language']) {
+ $selectedLanguages = array_values(array_filter(
+ $languages,
+ static fn (array $language): bool => $options['language'] === $language['id']
+ ));
+ } else {
+ $selectedLanguages = array_values(array_filter(
+ $languages,
+ static fn (array $language): bool => $language['featured']
+ ));
+ }
+
+ if (null !== $options['theme']) {
+ $selectedThemes = [$options['theme'] => $themes[$options['theme']]];
+ } else {
+ $selectedThemes = array_intersect_key($themes, array_flip(defaultThemeIds()));
+ }
+ }
+
+ $selection = [];
+ foreach ($selectedLanguages as $language) {
+ foreach ($selectedThemes as $theme) {
+ $selection[] = [$language, $theme];
+ }
+ }
+
+ if ([] === $selection) {
+ throw new RuntimeException('The selected matrix is empty. Mark at least one catalog entry as featured.');
+ }
+
+ return $selection;
+}
+
+/**
+ * @return list
+ */
+function defaultThemeIds(): array
+{
+ return ['alto-dark', 'alto-light', 'github-dark', 'github-light'];
+}
+
+/**
+ * @param array{language: ?string, theme: ?string, all: bool, help: bool} $options
+ */
+function selectionName(array $options): string
+{
+ if ($options['all']) {
+ return 'all';
+ }
+
+ if (null !== $options['language'] || null !== $options['theme']) {
+ return 'filtered';
+ }
+
+ return 'default';
+}
+
+/**
+ * @return array{css: string, embedded: bool}
+ */
+function loadFont(string $toolRoot): array
+{
+ $fontPath = $toolRoot.'/node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2';
+ if (!is_file($fontPath)) {
+ fwrite(STDERR, "Warning: local JetBrains Mono font not installed; using the system monospace fallback.\n");
+
+ return ['css' => '', 'embedded' => false];
+ }
+
+ $fontData = readRequiredFile($fontPath);
+ $encoded = base64_encode($fontData);
+
+ return [
+ 'css' => << true,
+ ];
+}
+
+function normalizeNewlines(string $source): string
+{
+ return str_replace(["\r\n", "\r"], "\n", $source);
+}
+
+function sourceForDisplay(string $source): string
+{
+ return rtrim(normalizeNewlines($source), "\n");
+}
+
+function visibleLineCount(string $source): int
+{
+ if ('' === $source) {
+ return 0;
+ }
+
+ if (str_ends_with($source, "\n")) {
+ $source = substr($source, 0, -1);
+ }
+
+ return 1 + substr_count($source, "\n");
+}
+
+function readRequiredFile(string $path): string
+{
+ $contents = @file_get_contents($path);
+ if (false === $contents) {
+ throw new RuntimeException(sprintf('Unable to read %s.', $path));
+ }
+
+ return $contents;
+}
+
+function ensureDirectory(string $path): void
+{
+ if (is_dir($path)) {
+ return;
+ }
+
+ if (!mkdir($path, 0777, true) && !is_dir($path)) {
+ throw new RuntimeException(sprintf('Unable to create directory %s.', $path));
+ }
+}
+
+function writeFile(string $path, string $contents): void
+{
+ if (false === file_put_contents($path, $contents)) {
+ throw new RuntimeException(sprintf('Unable to write %s.', $path));
+ }
+}
+
+/**
+ * @param array $data
+ */
+function writeJson(string $path, array $data): void
+{
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ writeFile($path, $json."\n");
+}
+
+/**
+ * @param array $variables
+ */
+function renderTemplate(string $path, array $variables): string
+{
+ if (!is_file($path)) {
+ throw new RuntimeException(sprintf('Template not found: %s.', $path));
+ }
+
+ extract($variables, EXTR_SKIP);
+ ob_start();
+
+ try {
+ require $path;
+
+ return (string) ob_get_clean();
+ } catch (Throwable $exception) {
+ ob_end_clean();
+ throw $exception;
+ }
+}
diff --git a/tools/docs-showcase/bin/verify.mjs b/tools/docs-showcase/bin/verify.mjs
new file mode 100644
index 0000000..1d185fd
--- /dev/null
+++ b/tools/docs-showcase/bin/verify.mjs
@@ -0,0 +1,245 @@
+#!/usr/bin/env node
+
+import { access, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+import { fileURLToPath } from 'node:url';
+
+const CARD_WIDTH = 800;
+const CARD_HEIGHT = 400;
+const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
+const toolRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const buildRoot = path.join(toolRoot, 'build');
+
+try {
+ const options = parseOptions(process.argv.slice(2));
+
+ if (options.help) {
+ printHelp();
+ process.exit(0);
+ }
+
+ await verifyPackageContracts();
+
+ const manifest = await readJson(path.join(buildRoot, 'manifest.json'));
+ const captures = await readJson(path.join(buildRoot, 'captures.json'));
+ verifyManifest(manifest, options);
+ verifyCaptures(manifest, captures);
+
+ await access(path.join(buildRoot, 'gallery.html'));
+
+ for (const card of manifest.cards) {
+ await verifyHtml(card);
+ }
+
+ for (const capture of captures.captures) {
+ await verifyPng(capture);
+ }
+
+ process.stdout.write(
+ `Verified ${manifest.cards.length} HTML card${manifest.cards.length === 1 ? '' : 's'} and ` +
+ `${captures.captures.length} PNG asset${captures.captures.length === 1 ? '' : 's'} at 800x400.\n`,
+ );
+} catch (error) {
+ process.stderr.write(`Error: ${formatError(error)}\n`);
+ process.exitCode = error && error.usage ? 2 : 1;
+}
+
+function parseOptions(arguments_) {
+ const options = { all: false, help: false };
+
+ for (const argument of arguments_) {
+ if (argument === '-h' || argument === '--help') {
+ options.help = true;
+ } else if (argument === '--all') {
+ options.all = true;
+ } else {
+ const error = new Error(`Unknown argument "${argument}".`);
+ error.usage = true;
+ throw error;
+ }
+ }
+
+ return options;
+}
+
+function printHelp() {
+ process.stdout.write(`Verify generated Alto documentation cards and PNG assets.
+
+Usage:
+ node bin/verify.mjs [--all]
+
+Options:
+ --all Require a complete language/theme matrix.
+ -h, --help Show this help.
+`);
+}
+
+async function verifyPackageContracts() {
+ const packageJson = await readJson(path.join(toolRoot, 'package.json'));
+ const requiredScripts = ['generate', 'capture', 'verify', 'refresh'];
+
+ if (packageJson.private !== true) {
+ throw new Error('package.json must remain private.');
+ }
+ for (const script of requiredScripts) {
+ if (typeof packageJson.scripts?.[script] !== 'string' || packageJson.scripts[script] === '') {
+ throw new Error(`package.json is missing the "${script}" script.`);
+ }
+ }
+ for (const [name, version] of Object.entries(packageJson.devDependencies ?? {})) {
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
+ throw new Error(`Development dependency "${name}" must use an exact version.`);
+ }
+ }
+
+ const composerJson = await readJson(path.join(toolRoot, 'composer.json'));
+ const pathRepository = composerJson.repositories?.find(
+ (repository) => repository.type === 'path' && repository.url === '../..',
+ );
+ if (!pathRepository) {
+ throw new Error('composer.json must use ../.. as an Alto path repository.');
+ }
+ if (composerJson.require?.['alto/code-highlight'] !== 'dev-main') {
+ throw new Error('composer.json must require the local Alto development package.');
+ }
+}
+
+function verifyManifest(manifest, options) {
+ if (!manifest || manifest.schemaVersion !== 1 || !Array.isArray(manifest.cards)) {
+ throw new Error('build/manifest.json has an unsupported format.');
+ }
+ if (!manifest.catalog || !Array.isArray(manifest.catalog.languageIds) || !Array.isArray(manifest.catalog.themeIds)) {
+ throw new Error('build/manifest.json is missing catalog metadata.');
+ }
+ if (manifest.expectedCount !== manifest.cards.length || manifest.cards.length === 0) {
+ throw new Error('build/manifest.json has an invalid card count.');
+ }
+
+ const expectedAllCount = manifest.catalog.languageIds.length * manifest.catalog.themeIds.length;
+ if (options.all && (manifest.selection !== 'all' || manifest.cards.length !== expectedAllCount)) {
+ throw new Error(`Expected the full ${expectedAllCount}-card matrix; regenerate with --all.`);
+ }
+
+ const keys = new Set();
+ for (const card of manifest.cards) {
+ const key = `${card.theme?.id}/${card.language?.id}`;
+ if (keys.has(key)) {
+ throw new Error(`Duplicate manifest card "${key}".`);
+ }
+ keys.add(key);
+
+ if (!manifest.catalog.languageIds.includes(card.language?.id)) {
+ throw new Error(`Manifest card "${key}" uses an unknown language.`);
+ }
+ if (!manifest.catalog.themeIds.includes(card.theme?.id)) {
+ throw new Error(`Manifest card "${key}" uses an unknown theme.`);
+ }
+ if (card.lineCount < 8 || card.lineCount > 13) {
+ throw new Error(`Manifest card "${key}" does not have between 8 and 13 lines.`);
+ }
+ if (card.html !== `cards/${card.theme.id}/${card.language.id}.html`) {
+ throw new Error(`Manifest card "${key}" has a non-canonical HTML path.`);
+ }
+ if (!/^[a-f0-9]{64}$/u.test(card.sourceSha256 ?? '')) {
+ throw new Error(`Manifest card "${key}" has an invalid source checksum.`);
+ }
+ }
+}
+
+function verifyCaptures(manifest, captures) {
+ if (!captures || captures.schemaVersion !== 1 || !Array.isArray(captures.captures)) {
+ throw new Error('build/captures.json has an unsupported format. Run the capture command.');
+ }
+ if (captures.expectedCount !== captures.captures.length || captures.captures.length === 0) {
+ throw new Error('build/captures.json has an invalid capture count.');
+ }
+ if (captures.captures.length !== manifest.cards.length) {
+ throw new Error('Captured assets do not cover the current build manifest.');
+ }
+
+ const manifestByKey = new Map(
+ manifest.cards.map((card) => [`${card.theme.id}/${card.language.id}`, card]),
+ );
+ const seen = new Set();
+
+ for (const capture of captures.captures) {
+ const key = `${capture.theme}/${capture.language}`;
+ const card = manifestByKey.get(key);
+ if (!card) {
+ throw new Error(`Capture "${key}" is not present in the current manifest.`);
+ }
+ if (seen.has(key)) {
+ throw new Error(`Duplicate capture "${key}".`);
+ }
+ seen.add(key);
+ if (capture.sourceSha256 !== card.sourceSha256) {
+ throw new Error(`Capture "${key}" was produced from stale source.`);
+ }
+ }
+}
+
+async function verifyHtml(card) {
+ const filePath = path.join(buildRoot, card.html);
+ const html = await readFile(filePath, 'utf8');
+ const key = `${card.theme.id}/${card.language.id}`;
+
+ if (!html.includes('id="showcase-card"')) {
+ throw new Error(`${key}: generated HTML is missing the showcase card.`);
+ }
+ if (!html.includes(`data-language="${escapeHtml(card.language.id)}"`)) {
+ throw new Error(`${key}: generated HTML has the wrong language metadata.`);
+ }
+ if (!html.includes(`data-theme="${escapeHtml(card.theme.id)}"`)) {
+ throw new Error(`${key}: generated HTML has the wrong theme metadata.`);
+ }
+ if (!html.includes(`data-source-sha256="${card.sourceSha256}"`)) {
+ throw new Error(`${key}: generated HTML has stale source metadata.`);
+ }
+ if (
+ /<(?:script|img|iframe)\b[^>]+\bsrc\s*=\s*["']https?:/iu.test(html) ||
+ /]+\bhref\s*=\s*["']https?:/iu.test(html) ||
+ /@import\s+(?:url\()?["']?https?:/iu.test(html)
+ ) {
+ throw new Error(`${key}: generated HTML contains an external resource.`);
+ }
+}
+
+async function verifyPng(capture) {
+ const filePath = path.resolve(toolRoot, capture.png);
+ const buffer = await readFile(filePath);
+ const key = `${capture.theme}/${capture.language}`;
+
+ if (buffer.length < 24 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
+ throw new Error(`${key}: capture is not a valid PNG file.`);
+ }
+
+ const width = buffer.readUInt32BE(16);
+ const height = buffer.readUInt32BE(20);
+ if (width !== CARD_WIDTH || height !== CARD_HEIGHT) {
+ throw new Error(`${key}: PNG is ${width}x${height}, expected ${CARD_WIDTH}x${CARD_HEIGHT}.`);
+ }
+}
+
+async function readJson(filePath) {
+ try {
+ return JSON.parse(await readFile(filePath, 'utf8'));
+ } catch (error) {
+ if (error && error.code === 'ENOENT') {
+ throw new Error(`Missing ${path.relative(process.cwd(), filePath)}.`);
+ }
+ throw error;
+ }
+}
+
+function escapeHtml(value) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>');
+}
+
+function formatError(error) {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/tools/docs-showcase/composer.json b/tools/docs-showcase/composer.json
new file mode 100644
index 0000000..102c302
--- /dev/null
+++ b/tools/docs-showcase/composer.json
@@ -0,0 +1,27 @@
+{
+ "name": "alto/code-highlight-docs-showcase",
+ "description": "Local documentation card generator for Alto Code Highlight.",
+ "type": "project",
+ "license": "MIT",
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../..",
+ "options": {
+ "symlink": true
+ }
+ }
+ ],
+ "require": {
+ "php": "^8.4",
+ "alto/code-highlight": "dev-main"
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "config": {
+ "sort-packages": true
+ },
+ "scripts": {
+ "generate": "php bin/generate.php"
+ }
+}
diff --git a/tools/docs-showcase/composer.lock b/tools/docs-showcase/composer.lock
new file mode 100644
index 0000000..019888e
--- /dev/null
+++ b/tools/docs-showcase/composer.lock
@@ -0,0 +1,126 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "1951e82257ebed075fd049805ff7c640",
+ "packages": [
+ {
+ "name": "alto/code-highlight",
+ "version": "dev-main",
+ "dist": {
+ "type": "path",
+ "url": "../..",
+ "reference": "426ed1ccef0e6c7cfa5f72a8683bd17c3a648e97"
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "ext-tokenizer": "*",
+ "php": "^8.4"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "^3.68",
+ "phpstan/phpstan": "^2.0",
+ "phpunit/phpunit": "^12.0"
+ },
+ "suggest": {
+ "ext-simplexml": "Required by the optional TextMate theme adapter"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Alto\\Code\\Highlight\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Alto\\Code\\Highlight\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "cs": [
+ "vendor/bin/php-cs-fixer fix --diff"
+ ],
+ "docs:capture": [
+ "npm --prefix tools/docs-showcase run capture"
+ ],
+ "docs:generate": [
+ "composer --working-dir=tools/docs-showcase generate"
+ ],
+ "docs:refresh": [
+ "@docs:generate",
+ "@docs:capture",
+ "@docs:verify"
+ ],
+ "docs:verify": [
+ "npm --prefix tools/docs-showcase run verify"
+ ],
+ "qa": [
+ "@cs",
+ "@sa",
+ "@test"
+ ],
+ "sa": [
+ "vendor/bin/phpstan analyse --memory-limit=-1"
+ ],
+ "test": [
+ "vendor/bin/phpunit"
+ ],
+ "coverage": [
+ "vendor/bin/phpunit --coverage-text"
+ ]
+ },
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Simon André",
+ "email": "smn.andre@gmail.com"
+ }
+ ],
+ "description": "Server-side syntax highlighting for PHP with 27 languages, semantic scopes, embedded languages, and adaptable themes",
+ "homepage": "https://github.com/altophp/code-highlight",
+ "keywords": [
+ "code-highlighting",
+ "highlight-js",
+ "php",
+ "php84",
+ "prism",
+ "semantic-parser",
+ "server-side",
+ "syntax-highlighter",
+ "themes",
+ "tokenizer"
+ ],
+ "support": {
+ "issues": "https://github.com/altophp/code-highlight/issues",
+ "docs": "https://github.com/altophp/code-highlight/blob/main/docs/index.md"
+ },
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/smnandre"
+ }
+ ],
+ "transport-options": {
+ "symlink": true,
+ "relative": true
+ }
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": {
+ "alto/code-highlight": 20
+ },
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^8.4"
+ },
+ "platform-dev": {},
+ "plugin-api-version": "2.9.0"
+}
diff --git a/tools/docs-showcase/package-lock.json b/tools/docs-showcase/package-lock.json
new file mode 100644
index 0000000..80a742d
--- /dev/null
+++ b/tools/docs-showcase/package-lock.json
@@ -0,0 +1,73 @@
+{
+ "name": "@alto/code-highlight-docs-showcase",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@alto/code-highlight-docs-showcase",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@fontsource/jetbrains-mono": "5.2.8",
+ "playwright": "1.61.1"
+ }
+ },
+ "node_modules/@fontsource/jetbrains-mono": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
+ "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
+ "dev": true,
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/tools/docs-showcase/package.json b/tools/docs-showcase/package.json
new file mode 100644
index 0000000..447fff4
--- /dev/null
+++ b/tools/docs-showcase/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@alto/code-highlight-docs-showcase",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "generate": "php bin/generate.php",
+ "capture": "node bin/capture.mjs",
+ "verify": "node bin/verify.mjs",
+ "refresh": "npm run generate && npm run capture && npm run verify"
+ },
+ "devDependencies": {
+ "@fontsource/jetbrains-mono": "5.2.8",
+ "playwright": "1.61.1"
+ }
+}
diff --git a/tools/docs-showcase/templates/card.php b/tools/docs-showcase/templates/card.php
new file mode 100644
index 0000000..26c4a82
--- /dev/null
+++ b/tools/docs-showcase/templates/card.php
@@ -0,0 +1,135 @@
+ htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+$dense = $lineCount > 8;
+$sourceJson = json_encode(
+ $source,
+ JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES
+);
+?>
+
+
+
+
+
+
+ = $escape($language['name']) ?> - = $escape($theme['name']) ?>
+
+
+
+
+
+ = $escape($language['name']) ?>
+ = $escape($theme['name']) ?>
+
+ = $highlighted ?>
+
+
+
+
diff --git a/tools/docs-showcase/templates/gallery.php b/tools/docs-showcase/templates/gallery.php
new file mode 100644
index 0000000..9c33a43
--- /dev/null
+++ b/tools/docs-showcase/templates/gallery.php
@@ -0,0 +1,117 @@
+ $cards
+ */
+
+$escape = static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+?>
+
+
+
+
+
+
+ Alto documentation showcase
+
+
+
+
+ Alto documentation showcase
+ = count($cards) ?> generated = 1 === count($cards) ? 'card' : 'cards' ?>, rendered from canonical examples.
+
+
+
+
+
+
+
+
+ = $escape($card['language']['name']) ?> · = $escape($card['theme']['name']) ?>
+
+
+
+
+
+