From 426877c80307036b85d2568cd787fd039cbd48dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Sun, 9 Aug 2026 22:28:11 +0200 Subject: [PATCH] Prepare 1.0.0 --- .gitattributes | 6 + CHANGELOG.md | 19 +- README.md | 210 +++--- composer.json | 17 +- docs/assets/examples/alto-dark/css.png | Bin 0 -> 21901 bytes docs/assets/examples/alto-dark/html.png | Bin 0 -> 41737 bytes docs/assets/examples/alto-dark/javascript.png | Bin 0 -> 38220 bytes docs/assets/examples/alto-dark/php.png | Bin 0 -> 24549 bytes docs/assets/examples/alto-dark/twig.png | Bin 0 -> 40252 bytes docs/assets/examples/alto-light/css.png | Bin 0 -> 21229 bytes docs/assets/examples/alto-light/html.png | Bin 0 -> 40141 bytes .../assets/examples/alto-light/javascript.png | Bin 0 -> 37164 bytes docs/assets/examples/alto-light/php.png | Bin 0 -> 24063 bytes docs/assets/examples/alto-light/twig.png | Bin 0 -> 38543 bytes docs/assets/examples/github-dark/css.png | Bin 0 -> 22140 bytes docs/assets/examples/github-dark/html.png | Bin 0 -> 43252 bytes .../examples/github-dark/javascript.png | Bin 0 -> 39558 bytes docs/assets/examples/github-dark/php.png | Bin 0 -> 25350 bytes docs/assets/examples/github-dark/twig.png | Bin 0 -> 41714 bytes docs/assets/examples/github-light/css.png | Bin 0 -> 21157 bytes docs/assets/examples/github-light/html.png | Bin 0 -> 42230 bytes .../examples/github-light/javascript.png | Bin 0 -> 38419 bytes docs/assets/examples/github-light/php.png | Bin 0 -> 24796 bytes docs/assets/examples/github-light/twig.png | Bin 0 -> 40393 bytes docs/creating-a-theme.md | 217 ++++++ docs/embedded-languages.md | 134 ++++ docs/examples.md | 107 +++ docs/getting-started.md | 164 +++++ docs/index.md | 45 ++ docs/installation.md | 99 +++ docs/languages.md | 92 +++ docs/public-api.md | 65 ++ docs/theme-adapters.md | 116 ++++ docs/themes.md | 85 +++ examples/catalog.php | 258 ++++++++ examples/languages/bash.sh | 8 + examples/languages/csharp.cs | 8 + examples/languages/css.css | 8 + examples/languages/diff.diff | 8 + examples/languages/dockerfile.Dockerfile | 8 + examples/languages/dotenv.env | 8 + examples/languages/go.go | 8 + examples/languages/html.html | 8 + examples/languages/http.http | 8 + examples/languages/ini.ini | 8 + examples/languages/java.java | 8 + examples/languages/javascript.js | 8 + examples/languages/json.json | 8 + examples/languages/makefile.mk | 8 + examples/languages/markdown.md | 8 + examples/languages/php.php | 13 + examples/languages/python.py | 8 + examples/languages/ruby.rb | 8 + examples/languages/rust.rs | 8 + examples/languages/scss.scss | 8 + examples/languages/sql.sql | 8 + examples/languages/svg.svg | 8 + examples/languages/swift.swift | 8 + examples/languages/twig.twig | 8 + examples/languages/typescript.ts | 8 + examples/languages/xml.xml | 8 + examples/languages/yaml.yaml | 8 + tests/Unit/ExamplesCatalogTest.php | 140 ++++ tools/docs-showcase/.gitignore | 3 + tools/docs-showcase/README.md | 69 ++ tools/docs-showcase/bin/capture.mjs | 332 ++++++++++ tools/docs-showcase/bin/generate.php | 623 ++++++++++++++++++ tools/docs-showcase/bin/verify.mjs | 245 +++++++ tools/docs-showcase/composer.json | 27 + tools/docs-showcase/composer.lock | 126 ++++ tools/docs-showcase/package-lock.json | 73 ++ tools/docs-showcase/package.json | 16 + tools/docs-showcase/templates/card.php | 135 ++++ tools/docs-showcase/templates/gallery.php | 117 ++++ 74 files changed, 3642 insertions(+), 119 deletions(-) create mode 100644 docs/assets/examples/alto-dark/css.png create mode 100644 docs/assets/examples/alto-dark/html.png create mode 100644 docs/assets/examples/alto-dark/javascript.png create mode 100644 docs/assets/examples/alto-dark/php.png create mode 100644 docs/assets/examples/alto-dark/twig.png create mode 100644 docs/assets/examples/alto-light/css.png create mode 100644 docs/assets/examples/alto-light/html.png create mode 100644 docs/assets/examples/alto-light/javascript.png create mode 100644 docs/assets/examples/alto-light/php.png create mode 100644 docs/assets/examples/alto-light/twig.png create mode 100644 docs/assets/examples/github-dark/css.png create mode 100644 docs/assets/examples/github-dark/html.png create mode 100644 docs/assets/examples/github-dark/javascript.png create mode 100644 docs/assets/examples/github-dark/php.png create mode 100644 docs/assets/examples/github-dark/twig.png create mode 100644 docs/assets/examples/github-light/css.png create mode 100644 docs/assets/examples/github-light/html.png create mode 100644 docs/assets/examples/github-light/javascript.png create mode 100644 docs/assets/examples/github-light/php.png create mode 100644 docs/assets/examples/github-light/twig.png create mode 100644 docs/creating-a-theme.md create mode 100644 docs/embedded-languages.md create mode 100644 docs/examples.md create mode 100644 docs/getting-started.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/languages.md create mode 100644 docs/public-api.md create mode 100644 docs/theme-adapters.md create mode 100644 docs/themes.md create mode 100644 examples/catalog.php create mode 100644 examples/languages/bash.sh create mode 100644 examples/languages/csharp.cs create mode 100644 examples/languages/css.css create mode 100644 examples/languages/diff.diff create mode 100644 examples/languages/dockerfile.Dockerfile create mode 100644 examples/languages/dotenv.env create mode 100644 examples/languages/go.go create mode 100644 examples/languages/html.html create mode 100644 examples/languages/http.http create mode 100644 examples/languages/ini.ini create mode 100644 examples/languages/java.java create mode 100644 examples/languages/javascript.js create mode 100644 examples/languages/json.json create mode 100644 examples/languages/makefile.mk create mode 100644 examples/languages/markdown.md create mode 100644 examples/languages/php.php create mode 100644 examples/languages/python.py create mode 100644 examples/languages/ruby.rb create mode 100644 examples/languages/rust.rs create mode 100644 examples/languages/scss.scss create mode 100644 examples/languages/sql.sql create mode 100644 examples/languages/svg.svg create mode 100644 examples/languages/swift.swift create mode 100644 examples/languages/twig.twig create mode 100644 examples/languages/typescript.ts create mode 100644 examples/languages/xml.xml create mode 100644 examples/languages/yaml.yaml create mode 100644 tests/Unit/ExamplesCatalogTest.php create mode 100644 tools/docs-showcase/.gitignore create mode 100644 tools/docs-showcase/README.md create mode 100644 tools/docs-showcase/bin/capture.mjs create mode 100644 tools/docs-showcase/bin/generate.php create mode 100644 tools/docs-showcase/bin/verify.mjs create mode 100644 tools/docs-showcase/composer.json create mode 100644 tools/docs-showcase/composer.lock create mode 100644 tools/docs-showcase/package-lock.json create mode 100644 tools/docs-showcase/package.json create mode 100644 tools/docs-showcase/templates/card.php create mode 100644 tools/docs-showcase/templates/gallery.php 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 | +|---|---| +| ![PHP highlighted with Alto Dark](assets/examples/alto-dark/php.png) | ![PHP highlighted with Alto Light](assets/examples/alto-light/php.png) | + +| GitHub Dark | GitHub Light | +|---|---| +| ![PHP highlighted with GitHub Dark](assets/examples/github-dark/php.png) | ![PHP highlighted with GitHub Light](assets/examples/github-light/php.png) | + +### Twig + +| Alto Dark | Alto Light | +|---|---| +| ![Twig highlighted with Alto Dark](assets/examples/alto-dark/twig.png) | ![Twig highlighted with Alto Light](assets/examples/alto-light/twig.png) | + +| GitHub Dark | GitHub Light | +|---|---| +| ![Twig highlighted with GitHub Dark](assets/examples/github-dark/twig.png) | ![Twig highlighted with GitHub Light](assets/examples/github-light/twig.png) | + +### HTML + +| Alto Dark | Alto Light | +|---|---| +| ![HTML highlighted with Alto Dark](assets/examples/alto-dark/html.png) | ![HTML highlighted with Alto Light](assets/examples/alto-light/html.png) | + +| GitHub Dark | GitHub Light | +|---|---| +| ![HTML highlighted with GitHub Dark](assets/examples/github-dark/html.png) | ![HTML highlighted with GitHub Light](assets/examples/github-light/html.png) | + +### JavaScript + +| Alto Dark | Alto Light | +|---|---| +| ![JavaScript highlighted with Alto Dark](assets/examples/alto-dark/javascript.png) | ![JavaScript highlighted with Alto Light](assets/examples/alto-light/javascript.png) | + +| GitHub Dark | GitHub Light | +|---|---| +| ![JavaScript highlighted with GitHub Dark](assets/examples/github-dark/javascript.png) | ![JavaScript highlighted with GitHub Light](assets/examples/github-light/javascript.png) | + +### CSS + +| Alto Dark | Alto Light | +|---|---| +| ![CSS highlighted with Alto Dark](assets/examples/alto-dark/css.png) | ![CSS highlighted with Alto Light](assets/examples/alto-light/css.png) | + +| GitHub Dark | GitHub Light | +|---|---| +| ![CSS highlighted with GitHub Dark](assets/examples/github-dark/css.png) | ![CSS highlighted with GitHub Light](assets/examples/github-light/css.png) | + +## 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'); +?> + + + + + Alto example + + + + + + +``` + +The highlighter escapes source text before it creates HTML. Insert its return +value as trusted generated markup; escaping that value again would display the +`
`, ``, 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 |
+|---|---|
+| ![PHP highlighted with Alto Dark](assets/examples/alto-dark/php.png) | ![PHP highlighted with Alto Light](assets/examples/alto-light/php.png) |
+
+| GitHub Dark | GitHub Light |
+|---|---|
+| ![PHP highlighted with GitHub Dark](assets/examples/github-dark/php.png) | ![PHP highlighted with GitHub Light](assets/examples/github-light/php.png) |
+
+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 @@ + + Code highlight + + + + + + 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']) ?> + + + +
+
+ + +
+ +
+ + + 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

+

generated , rendered from canonical examples.

+
+
+ + + +
+ +