> $tokens
+ */
+ private function detectTabWidthFromTokens(array $tokens): int
+ {
+ $indentSizes = [];
- // Wrapper styles/classes/etc.
+ foreach ($tokens as $lineTokens) {
+ if (empty($lineTokens)) {
+ continue;
+ }
+
+ /** @var HighlightedToken $firstToken */
+ $firstToken = $lineTokens[0];
+ $text = $firstToken->token->text;
- $wrapperClasses = array_filter([
+ // Only measure whitespace-only tokens for indent detection.
+ if ($text === '' || trim((string) $text) !== '') {
+ continue;
+ }
+
+ $size = strlen(str_replace("\t", ' ', $text));
+
+ if ($size > 0) {
+ $indentSizes[] = $size;
+ }
+ }
+
+ return IndentGuideTransformer::computeTabWidth($indentSizes);
+ }
+
+ /**
+ * @param HighlightedToken[] $lineTokens
+ */
+ private function measureLineIndent(array $lineTokens, int $tabWidth): int
+ {
+ $columns = 0;
+
+ foreach ($lineTokens as $token) {
+ $text = $token->token->text;
+
+ if (trim($text) !== '') {
+ break;
+ }
+
+ for ($i = 0; $i < strlen($text); $i++) {
+ if ($text[$i] === "\t") {
+ $columns += $tabWidth - ($columns % $tabWidth);
+ } else {
+ $columns++;
+ }
+ }
+ }
+
+ return $columns;
+ }
+
+ protected function buildWrapperMetadata(RenderedBlock $block): void
+ {
+ $wrapperClasses = array_values(array_filter([
'phiki',
$this->grammarName ? "language-$this->grammarName" : null,
- $this->getDefaultTheme()->name,
+ $this->themeResolver()->getDefaultTheme()->name,
count($this->themes) > 1 ? 'phiki-themes' : null,
- ]);
+ ]));
foreach ($this->themes as $theme) {
- if ($theme !== $this->getDefaultTheme()) {
+ if ($theme !== $this->themeResolver()->getDefaultTheme()) {
$wrapperClasses[] = $theme->name;
}
}
@@ -170,10 +471,10 @@ public function renderBlock(array $tokens): RenderedBlock
$block->wrapperClasses = $wrapperClasses;
$block->wrapperClassString = implode(' ', $wrapperClasses);
- $wrapperStyles = [$this->getDefaultTheme()->base()->toStyleString()];
+ $wrapperStyles = [$this->themeResolver()->getDefaultTheme()->base()->toStyleString()];
foreach ($this->themes as $id => $theme) {
- if ($id !== $this->getDefaultThemeId()) {
+ if ($id !== $this->themeResolver()->getDefaultThemeId()) {
$wrapperStyles[] = $theme->base()->toCssVarString($id);
}
}
@@ -183,69 +484,48 @@ public function renderBlock(array $tokens): RenderedBlock
if (count($wrapperStyles) > 0) {
$block->wrapperStyleString = implode(';', $wrapperStyles);
}
+ }
- $result = implode('', $output);
- $result = strtr($result, $this->generationOptions?->textReplacements ?? []);
+ protected function applyPostRenderHooks(string $result): string
+ {
+ if (! $this->torchlightOptions()->outputTextShadows) {
+ return $result;
+ }
foreach ($this->themes as $id => $theme) {
$propertyPrefix = '';
- if ($id !== $this->getDefaultThemeId()) {
- $propertyPrefix = $this->getPhikiPropertyName($id, '');
+ if ($id !== $this->themeResolver()->getDefaultThemeId()) {
+ $propertyPrefix = $this->themeResolver()->getPhikiPropertyName($id, '');
}
- // Currently the render hooks apply the text shadow/glow.
- // If those have been disabled, we can skip the hooks
- if ($this->torchlightOptions->outputTextShadows) {
- $result = $this->runAfterRenderHooks(
- $theme->name,
- $result,
- $this->torchlightOptions,
- $propertyPrefix,
- $id,
- );
- }
+ $result = $this->runAfterRenderHooks(
+ $theme->name,
+ $result,
+ $this->torchlightOptions(),
+ $propertyPrefix,
+ $id,
+ );
}
- $block->code = implode('', [
- '',
- $result,
- ...$beforeClosing,
- ]);
-
- return $block;
- }
-
- public function generate(array $tokens): string
- {
- return $this->buildPre(
- $this->renderBlock($tokens)
- );
- }
-
- private function buildPre(RenderedBlock $block): string
- {
- return implode('', [
- '',
- $this->buildCode($block),
- '
',
- ]);
+ return $result;
}
+ /** @return array */
protected function makeThemeAttributes(): array
{
$attributes = [];
foreach ($this->themes as $id => $theme) {
- $name = htmlspecialchars($theme->name);
+ $name = htmlspecialchars((string) $theme->name);
- if ($id === $this->getDefaultThemeId()) {
+ if ($id === $this->themeResolver()->getDefaultThemeId()) {
$attributes['data-theme'] = $name;
continue;
}
- $id = htmlspecialchars($id);
+ $id = htmlspecialchars((string) $id);
$attributes["data-theme:{$id}"] = $name;
}
@@ -253,284 +533,251 @@ protected function makeThemeAttributes(): array
return $attributes;
}
- private function buildCode(RenderedBlock $block): string
- {
- return implode('', [
- "attributeString} class='{$block->allClassesToString()}' style='{$block->allStylesToString()}'>",
- $block->code,
- '',
- ]);
- }
-
- private function makeCopyTarget(): string
- {
- $content = htmlspecialchars($this->cleanedText);
-
- return "{$content}
";
- }
-
private function buildLinePrepend(int $line): string
{
- return implode('', $this->generationOptions?->linePrepends[$line] ?? []);
+ return implode('', $this->generationOptions()->linePrepends[$line] ?? []);
}
private function buildLineAppend(int $line): string
{
- return implode('', $this->generationOptions?->lineAppends[$line] ?? []);
+ return implode('', $this->generationOptions()->lineAppends[$line] ?? []);
}
+ /**
+ * @param array $line
+ */
private function buildLine(array $line, int $index): string
{
$currentLine = $index + 1;
- $output = [];
- if ($this->generationOptions && array_key_exists($currentLine, $this->generationOptions->lineTokenCallbacks)) {
- foreach ($this->generationOptions->lineTokenCallbacks[$currentLine] as $tokenCallback) {
- $line = call_user_func_array($tokenCallback, [$line]);
- }
- }
+ $line = $this->applyLineTokenCallbacks($line, $currentLine);
- $classes = [
- 'line',
- ];
+ $this->applyGutterLineDecorations($index);
- if ($this->generationOptions) {
- if (array_key_exists($currentLine, $this->generationOptions->lineClasses)) {
- $classes = array_merge($classes, array_unique($this->generationOptions->lineClasses[$currentLine]));
- }
- }
+ $classes = $this->buildLineClasses($currentLine);
- if ($this->withGutter && $this->generationOptions) {
- foreach ($this->generationOptions->gutters as $gutter) {
- if (! $gutter->shouldRender()) {
- continue;
- }
+ $innerContent = $this->buildInnerLineContent($line, $index);
+ $innerContent = $this->applyLineContentCallbacks($innerContent, $line, $currentLine);
- $output[] = $gutter->renderLine($index + 1, $index, $line);
- }
- }
+ $gutterContent = $this->buildGutterContent($index, $line);
+ $guideHtml = $this->generationOptions()->columnGuideHtml;
+ $lineInnerContent = $guideHtml.implode('', array_merge($gutterContent, [$innerContent]));
- // Store the inner content separately to make it easier to apply character ranges if we have any.
- $innerLineOutput = [];
+ $lineElement = $this->buildLineElement($classes, $lineInnerContent, $currentLine);
- foreach ($line as $token) {
- $innerLineOutput[] = $this->buildToken($token);
- }
+ return implode('', [
+ $this->buildLinePrepend($currentLine),
+ $lineElement,
+ $this->buildLineAppend($currentLine),
+ ]);
+ }
- $innerContent = implode('', $innerLineOutput);
+ /**
+ * @param array $line
+ * @return array
+ */
+ private function applyLineTokenCallbacks(array $line, int $currentLine): array
+ {
+ $generationOptions = $this->generationOptions;
+ if ($generationOptions === null || ! array_key_exists($currentLine, $generationOptions->lineTokenCallbacks)) {
+ return $line;
+ }
- if (array_key_exists($index, $this->generationOptions->characterDecorators)) {
- $innerContent = $this->characterRangeDecorator->decorateCharacterRanges(
- $innerContent,
- $this->generationOptions->characterDecorators[$index]
- );
+ foreach ($generationOptions->lineTokenCallbacks[$currentLine] as $tokenCallback) {
+ $line = $tokenCallback($line);
}
- $output[] = $innerContent;
+ return $line;
+ }
+
+ /** @return list */
+ private function buildLineClasses(int $currentLine): array
+ {
+ $classes = ['line'];
- $styles = $this->toStyleString($this->getLineStyles($classes));
+ if ($this->generationOptions && array_key_exists($currentLine, $this->generationOptions->lineClasses)) {
+ $classes = array_merge($classes, array_unique($this->generationOptions->lineClasses[$currentLine]));
+ }
- if ($styles != '') {
- $styles = "style=\"{$styles}\"";
+ if ($this->generationOptions && $this->generationOptions->globalLineClasses !== []) {
+ $classes = array_merge($classes, $this->generationOptions->globalLineClasses);
}
- $attributes = $this->generationOptions?->lineAttributes[$currentLine] ?? [];
- $attributeString = '';
+ return $classes;
+ }
- if (! empty($attributes)) {
- $attributeString = ' '.$this->toAttributeString($attributes).' ';
+ private function applyGutterLineDecorations(int $index): void
+ {
+ if (! $this->torchlightOptions?->withGutter || ! $this->generationOptions) {
+ return;
}
- $lineInnerContent = implode($output);
-
- if ($this->generationOptions && array_key_exists($currentLine, $this->generationOptions->lineContentCallbacks)) {
- foreach ($this->generationOptions->lineContentCallbacks[$currentLine] as $callback) {
- $lineInnerContent = call_user_func_array($callback, [$lineInnerContent, $line]);
+ foreach ($this->generationOptions->getSortedGutters() as $gutter) {
+ if ($gutter->shouldRender()) {
+ $gutter->decorateLine($index + 1, $index, $this->generationOptions);
}
}
-
- $lineContent = ''.$lineInnerContent.'
';
-
- return implode('', [
- $this->buildLinePrepend($currentLine),
- $lineContent,
- $this->buildLineAppend($currentLine),
- ]);
}
- public function getThemeValueStyles(string $propertyName, array|string $themeProp, ?string $default = null): array
+ /**
+ * @param array $line
+ * @return list
+ */
+ private function buildGutterContent(int $index, array $line): array
{
- $styles = [];
-
- foreach ($this->themes as $id => $theme) {
- $propName = $propertyName;
-
- if (is_array($themeProp)) {
- foreach ($themeProp as $tryPropName) {
- $themeValue = $this->getValueFromTheme($theme, $tryPropName);
+ $output = [];
- if ($themeValue) {
- break;
- }
- }
- } else {
- $themeValue = $this->getValueFromTheme($theme, $themeProp);
- }
+ if (! $this->torchlightOptions?->withGutter || ! $this->generationOptions) {
+ return $output;
+ }
- $themeValue ??= $default;
+ $gutters = $this->generationOptions->getSortedGutters();
- if ($id != $this->getDefaultThemeId()) {
- $propName = $this->getPhikiPropertyName($id, $propertyName);
+ foreach ($gutters as $gutter) {
+ if ($gutter->shouldRender()) {
+ $output[] = $gutter->renderLine($index + 1, $index, $line);
}
-
- $styles[$propName] = $themeValue;
}
- return $styles;
+ return $output;
}
- public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string
+ /**
+ * @param array $line
+ */
+ private function buildInnerLineContent(array $line, int $index): string
{
- return $this->toStyleString($this->getThemeValueStyles($propertyName, $themeProp, $default));
- }
-
- private function adjustTokenStyles(HighlightedToken $token): HighlightedToken
- {
- $newSettings = [];
- $currentSettings = $token->settings;
-
- foreach ($this->themes as $id => $theme) {
- if (array_key_exists($id, $currentSettings)) {
- continue;
- }
+ $innerLineOutput = [];
- $currentSettings[$id] = new TokenSettings(null, null, null);
+ foreach ($line as $token) {
+ $innerLineOutput[] = $this->buildToken($token);
}
- foreach ($currentSettings as $id => $settings) {
- $foreground = $settings->foreground;
- $themeName = $this->themes[$id]?->name ?? '';
+ $innerContent = implode('', $innerLineOutput);
- if (mb_strlen(trim($token->token->text)) > 0) {
- if ($foreground === null) {
- $foreground = FallbackColors::getDefaultForeground($themeName);
- }
- }
+ $generationOptions = $this->generationOptions();
- $newSettings[$id] = new TokenSettings(
- null,
- $foreground,
- $this->torchlightOptions->outputFontStyles ? $settings->fontStyle : null
+ if (array_key_exists($index, $generationOptions->characterDecorators)) {
+ $innerContent = $this->characterRangeDecorator->decorateCharacterRanges(
+ $innerContent,
+ $generationOptions->characterDecorators[$index]
);
}
- return new HighlightedToken(
- $token->token,
- $newSettings
- );
- }
-
- public function getTokenStyles(object $token): array
- {
- return $this->getSettingsStylesArray(
- $this->adjustTokenStyles($token)->settings
- );
+ return $innerContent;
}
- public function getSettingsStylesArray(array $tokenSettings): array
+ /**
+ * @param array $line
+ */
+ private function applyLineContentCallbacks(string $content, array $line, int $currentLine): string
{
- $defaultThemeId = $this->getDefaultThemeId();
-
- $tokenStyles = [($tokenSettings[$defaultThemeId] ?? null)?->toStyleString()];
+ $generationOptions = $this->generationOptions;
+ if ($generationOptions === null || ! array_key_exists($currentLine, $generationOptions->lineContentCallbacks)) {
+ return $content;
+ }
- foreach ($tokenSettings as $id => $settings) {
- if ($id !== $defaultThemeId) {
- $tokenStyles[] = $settings->toCssVarString($id);
- }
+ foreach ($generationOptions->lineContentCallbacks[$currentLine] as $callback) {
+ $content = $callback($content, $line);
}
- return $tokenStyles;
+ return $content;
}
- public function getSettingsStyleString(array $tokenSettings): string
+ /**
+ * @param list $classes
+ */
+ private function buildLineElement(array $classes, string $content, int $currentLine): string
{
- return $this->toStyleString($this->getSettingsStylesArray($tokenSettings));
- }
+ $lineStyles = $this->themeResolver()->getLineStyles($classes);
+ $styles = $this->themeResolver()->toStyleString($lineStyles);
+ $styleAttr = $styles !== '' ? "style=\"{$styles}\"" : '';
- public function buildToken(object $token, array $classes = [], array $styles = []): string
- {
- $tokenStyles = $this->getTokenStyles($token);
- $tokenStyles = array_filter($tokenStyles);
- $styleString = '';
+ $attributes = $this->generationOptions()->lineAttributes[$currentLine] ?? [];
+ $attributeString = ! empty($attributes)
+ ? ' '.$this->themeResolver()->toAttributeString($attributes).' '
+ : '';
- if (count($tokenStyles) > 0) {
- $styleString = implode(';', $tokenStyles);
- }
+ return ''.$content.'
';
+ }
- if (! empty($styles)) {
- $incomingStyles = $this->toStyleString($styles);
+ /**
+ * @param list $classes
+ * @param array $styles
+ */
+ public function buildToken(RenderableToken $token, array $classes = [], array $styles = []): string
+ {
+ $highlighted = $token->highlighted;
+ $metadata = $token->metadata;
- if (! str_ends_with($incomingStyles, ';')) {
- $incomingStyles .= ';';
- }
+ $tokenStyles = array_filter($this->themeResolver()->getTokenStyles($highlighted));
+ $styleString = $this->themeResolver()->toStyleString(array_merge($styles, $tokenStyles));
- $styleString = $incomingStyles.$styleString;
+ if ($styleString !== '' && ! str_ends_with($styleString, ';')) {
+ $styleString .= ';';
}
+ $styleString = str_replace(';;', ';', $styleString);
if (empty($classes)) {
$classes = ['token'];
}
$attributes = [];
-
- if (isset($this->tokenOptions[$token])) {
- $options = $this->tokenOptions[$token];
-
- if (isset($options['classes'])) {
- $classes = array_merge($classes, $options['classes']);
- }
-
- if (isset($options['attributes'])) {
- $attributes = array_merge($attributes, $options['attributes']);
- }
+ if ($metadata->hasClasses()) {
+ $classes = array_merge($classes, $metadata->classes);
+ }
+ if ($metadata->hasAttributes()) {
+ $attributes = array_merge($attributes, $metadata->attributes);
}
- $attributeString = $this->toAttributeString($attributes);
+ $attributeString = $this->themeResolver()->toAttributeString($attributes);
if ($attributeString != '') {
$attributeString = ' '.$attributeString;
}
- if (mb_strlen(trim($styleString)) > 0 && ! str_ends_with($styleString, ';')) {
- $styleString .= ';';
- }
-
- $styleString = str_replace(';;', ';', $styleString);
-
return sprintf(
'%s',
$styleString ? " style=\"$styleString\"" : null,
$attributeString,
- $this->getTokenContent($token->token)
+ $this->getTokenContent($highlighted, $metadata)
);
}
- private function getTokenContent(Token $token): string
+ private function getTokenContent(HighlightedToken $token, ?TokenMetadata $metadata = null): string
{
- if (! isset($this->rawTokenContent[$token])) {
- return htmlspecialchars($token->text);
+ // If metadata indicates raw content, don't escape
+ if ($metadata !== null && $metadata->isRaw()) {
+ return $token->token->text;
}
- return $token->text;
+ return htmlspecialchars($token->token->text);
}
- public function getDefaultTheme(): ParsedTheme
+ private function torchlightOptions(): Options
{
- return Arr::first($this->themes);
+ if ($this->torchlightOptions === null) {
+ throw new \LogicException('Torchlight options have not been configured.');
+ }
+
+ return $this->torchlightOptions;
}
- public function getDefaultThemeId(): string
+ private function generationOptions(): GenerationOptions
{
- return Arr::firstKey($this->themes);
+ if ($this->generationOptions === null) {
+ throw new \LogicException('Generation options have not been configured.');
+ }
+
+ return $this->generationOptions;
+ }
+
+ private function themeResolver(): ThemeStyleResolver
+ {
+ if ($this->themeResolver === null) {
+ throw new \LogicException('Theme resolver has not been configured.');
+ }
+
+ return $this->themeResolver;
}
}
diff --git a/src/Generators/RenderContext.php b/src/Generators/RenderContext.php
new file mode 100644
index 0000000..44a29ad
--- /dev/null
+++ b/src/Generators/RenderContext.php
@@ -0,0 +1,82 @@
+ $themes
+ */
+ public function __construct(
+ public readonly Options $options,
+ public readonly array $themes,
+ public readonly string $grammarName,
+ private readonly ThemeStyleResolver $themeResolver,
+ private readonly HtmlGenerator $generator,
+ ) {}
+
+ /**
+ * @param list $scopes
+ * @return array
+ */
+ public function getScopeSettings(array $scopes): array
+ {
+ return $this->themeResolver->getScopeSettings($scopes);
+ }
+
+ /** @param list $scopes */
+ public function getScopeStyles(array $scopes): string
+ {
+ return $this->themeResolver->getScopeStyles($scopes);
+ }
+
+ /**
+ * @param list|string $themeProp
+ * @return array
+ */
+ public function getThemeValueStyles(string $propertyName, array|string $themeProp, ?string $default = null): array
+ {
+ return $this->themeResolver->getThemeValueStyles($propertyName, $themeProp, $default);
+ }
+
+ /** @param list|string $themeProp */
+ public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string
+ {
+ return $this->themeResolver->getThemeValueStylesString($propertyName, $themeProp, $default);
+ }
+
+ /**
+ * @param list $classes
+ * @param array $styles
+ */
+ public function buildToken(RenderableToken $token, array $classes = [], array $styles = []): string
+ {
+ return $this->generator->buildToken($token, $classes, $styles);
+ }
+
+ public function getDefaultTheme(): ParsedTheme
+ {
+ return $this->themeResolver->getDefaultTheme();
+ }
+
+ public function getDefaultThemeId(): string
+ {
+ return $this->themeResolver->getDefaultThemeId();
+ }
+
+ /** @param array $attributes */
+ public function toAttributeString(array $attributes): string
+ {
+ return $this->themeResolver->toAttributeString($attributes);
+ }
+
+ /** @param array $styles */
+ public function toStyleString(array $styles): string
+ {
+ return $this->themeResolver->toStyleString($styles);
+ }
+}
diff --git a/src/Generators/RenderableToken.php b/src/Generators/RenderableToken.php
new file mode 100644
index 0000000..f0071b6
--- /dev/null
+++ b/src/Generators/RenderableToken.php
@@ -0,0 +1,31 @@
+setRawContent($rawContent);
+ }
+
+ return new self($token, $metadata);
+ }
+
+ public static function raw(HighlightedToken $token, string $content): self
+ {
+ $token->token->text = $content;
+
+ return static::from($token, $content);
+ }
+}
diff --git a/src/Generators/RenderedBlock.php b/src/Generators/RenderedBlock.php
index 5fdb899..514214a 100644
--- a/src/Generators/RenderedBlock.php
+++ b/src/Generators/RenderedBlock.php
@@ -6,22 +6,27 @@ class RenderedBlock
{
public string $code = '';
+ /** @var array */
public array $attributes = [];
public string $attributeString = '';
+ /** @var array */
public array $styles = [];
public string $styleString = '';
+ /** @var list */
public array $wrapperStyles = [];
public string $wrapperStyleString = '';
+ /** @var list */
public array $wrapperClasses = [];
public string $wrapperClassString = '';
+ /** @var list */
public array $classes = [];
public string $classString = '';
@@ -37,4 +42,13 @@ public function allStylesToString(): string
{
return $this->wrapperStyleString.$this->styleString;
}
+
+ public function toHtml(): string
+ {
+ $code = "attributeString} class='{$this->allClassesToString()}' style='{$this->allStylesToString()}'>"
+ .$this->code
+ .'';
+
+ return ''.$code.'
';
+ }
}
diff --git a/src/Generators/ThemeStyleResolver.php b/src/Generators/ThemeStyleResolver.php
new file mode 100644
index 0000000..df37d20
--- /dev/null
+++ b/src/Generators/ThemeStyleResolver.php
@@ -0,0 +1,379 @@
+, 1:string, 2:string|null}>> */
+ protected array $styles = [
+ 'line-highlight' => [
+ [
+ ['editor.lineHighlightBackground', 'editor.selectionHighlightBackground', 'theme::background'],
+ 'background',
+ '#00000050',
+ ],
+ ],
+ 'line-add' => [
+ [
+ ['torchlight.markupInsertedBackground', 'diffEditor.insertedTextBackground'],
+ 'background',
+ '#89DDFF20',
+ ],
+ ],
+ 'line-remove' => [
+ [
+ ['torchlight.markupDeletedBackground', 'diffEditor.removedTextBackground'],
+ 'background',
+ '#ff9cac20',
+ ],
+ ],
+ ];
+
+ /**
+ * @param array $themes
+ */
+ public function __construct(
+ /** @var array */
+ protected array $themes,
+ protected ?Highlighter $highlighter = null,
+ protected ?Options $options = null,
+ ) {}
+
+ public function setHighlighter(Highlighter $highlighter): static
+ {
+ $this->highlighter = $highlighter;
+
+ return $this;
+ }
+
+ public function setOptions(Options $options): static
+ {
+ $this->options = $options;
+
+ return $this;
+ }
+
+ /**
+ * @param list|string $themeProp
+ * @return array
+ */
+ public function getThemeValueStyles(string $propertyName, array|string $themeProp, ?string $default = null): array
+ {
+ return $this->resolveThemeProperty($propertyName, $themeProp, $default);
+ }
+
+ /** @param list|string $themeProp */
+ public function getThemeValueStylesString(string $propertyName, array|string $themeProp, ?string $default = null): string
+ {
+ return $this->toStyleString($this->getThemeValueStyles($propertyName, $themeProp, $default));
+ }
+
+ /**
+ * @param list|string $themeProp
+ * @return array
+ */
+ private function resolveThemeProperty(string $propertyName, array|string $themeProp, ?string $default = null): array
+ {
+ $styles = [];
+
+ foreach ($this->themes as $id => $theme) {
+ $propName = $propertyName;
+ $themeValue = null;
+
+ if (is_array($themeProp)) {
+ foreach ($themeProp as $tryPropName) {
+ $themeValue = $this->getValueFromTheme($theme, $tryPropName);
+
+ if ($themeValue) {
+ break;
+ }
+ }
+ } else {
+ $themeValue = $this->getValueFromTheme($theme, $themeProp);
+ }
+
+ $themeValue ??= $default;
+
+ if ($id != $this->getDefaultThemeId()) {
+ $propName = $this->getPhikiPropertyName($id, $propertyName);
+ }
+
+ $styles[$propName] = $themeValue;
+ }
+
+ return $styles;
+ }
+
+ public function getValueFromTheme(ParsedTheme $theme, string $propName): ?string
+ {
+ if ($propName === 'theme::background') {
+ return $theme->base()->background;
+ } elseif ($propName === 'theme::foreground') {
+ return $theme->base()->foreground;
+ } elseif ($propName === 'theme::fontStyle') {
+ return $theme->base()->fontStyle;
+ }
+
+ return $theme->colors[$propName] ?? null;
+ }
+
+ /** @return list */
+ public function getTokenStyles(HighlightedToken $token): array
+ {
+ return $this->getSettingsStylesArray(
+ $this->adjustTokenStyles($token)->settings
+ );
+ }
+
+ /**
+ * @param array $tokenSettings
+ * @return list
+ */
+ public function getSettingsStylesArray(array $tokenSettings): array
+ {
+ $defaultThemeId = $this->getDefaultThemeId();
+
+ $tokenStyles = [];
+
+ $defaultSettings = $tokenSettings[$defaultThemeId] ?? null;
+ if ($defaultSettings !== null) {
+ $tokenStyles[] = $defaultSettings->toStyleString();
+ }
+
+ foreach ($tokenSettings as $id => $settings) {
+ if ($id !== $defaultThemeId) {
+ $tokenStyles[] = $settings->toCssVarString($id);
+ }
+ }
+
+ return $tokenStyles;
+ }
+
+ /**
+ * @param array $tokenSettings
+ */
+ public function getSettingsStyleString(array $tokenSettings): string
+ {
+ return $this->toStyleString($this->getSettingsStylesArray($tokenSettings));
+ }
+
+ protected function adjustTokenStyles(HighlightedToken $token): HighlightedToken
+ {
+ /** @var array $newSettings */
+ $newSettings = [];
+ /** @var array $currentSettings */
+ $currentSettings = $token->settings;
+
+ foreach ($this->themes as $id => $theme) {
+ if (array_key_exists($id, $currentSettings)) {
+ continue;
+ }
+
+ $currentSettings[$id] = new TokenSettings(null, null, null);
+ }
+
+ foreach ($currentSettings as $id => $settings) {
+ $foreground = $settings->foreground;
+ $theme = $this->themes[$id] ?? null;
+ $themeName = $theme === null ? '' : $theme->name;
+
+ if (mb_strlen(trim($token->token->text)) > 0) {
+ if ($foreground === null) {
+ $foreground = FallbackColors::getDefaultForeground($themeName);
+ }
+ }
+
+ $outputFontStyles = $this->options === null ? true : $this->options->outputFontStyles;
+
+ $newSettings[$id] = new TokenSettings(
+ null,
+ $foreground,
+ $outputFontStyles ? $settings->fontStyle : null
+ );
+ }
+
+ return new HighlightedToken(
+ $token->token,
+ $newSettings
+ );
+ }
+
+ /**
+ * @param list $scopes
+ * @return array
+ */
+ public function getScopeSettings(array $scopes): array
+ {
+ $highlightedToken = $this->makeToken('*', $scopes);
+
+ return $highlightedToken->settings;
+ }
+
+ /** @param list $scopes */
+ public function getScopeStyles(array $scopes): string
+ {
+ return $this->getSettingsStyleString($this->getScopeSettings($scopes));
+ }
+
+ /** @param list $scopes */
+ public function makeToken(string $text, array $scopes): HighlightedToken
+ {
+ $token = new Token($scopes, $text, 0, 0);
+ /** @var array> $highlightedLines */
+ $highlightedLines = $this->highlighter()->highlight([[$token]]);
+ if (! isset($highlightedLines[0][0]) || ! $highlightedLines[0][0] instanceof HighlightedToken) {
+ throw new LogicException('Unable to generate highlighted token.');
+ }
+
+ return $highlightedLines[0][0];
+ }
+
+ /** @return array */
+ public function getStyle(string $class): array
+ {
+ $styles = [];
+
+ if (isset($this->styles[$class])) {
+ foreach ($this->styles[$class] as $classProps) {
+ [$themeProp, $propertyName, $defaultValue] = $classProps;
+
+ foreach ($this->resolveThemeProperty($propertyName, $themeProp, $defaultValue) as $k => $v) {
+ $styles[$k] = $v;
+ }
+ }
+ }
+
+ return $styles;
+ }
+
+ /**
+ * @param list $classes
+ * @return array
+ */
+ public function getLineStyles(array $classes): array
+ {
+ $styles = [];
+
+ foreach ($classes as $class) {
+ foreach ($this->getStyle($class) as $k => $v) {
+ $styles[$k] = $v;
+ }
+ }
+
+ return $styles;
+ }
+
+ /**
+ * @param list|string $themeProp
+ */
+ public function registerLineStyle(string $class, array|string $themeProp, string $cssProperty, ?string $default = null): static
+ {
+ $this->styles[$class] ??= [];
+
+ $themeProperties = is_array($themeProp) ? array_values($themeProp) : [$themeProp];
+
+ $this->styles[$class][] = [
+ $themeProperties,
+ $cssProperty,
+ $default,
+ ];
+
+ return $this;
+ }
+
+ public function getDefaultTheme(): ParsedTheme
+ {
+ $firstKey = array_key_first($this->themes);
+
+ if ($firstKey === null) {
+ throw new LogicException('At least one theme is required.');
+ }
+
+ /** @var ParsedTheme $theme */
+ $theme = $this->themes[$firstKey];
+
+ return $theme;
+ }
+
+ public function getDefaultThemeId(): string
+ {
+ $firstKey = array_key_first($this->themes);
+
+ if (! is_string($firstKey)) {
+ throw new LogicException('At least one theme id is required.');
+ }
+
+ return $firstKey;
+ }
+
+ /** @return array */
+ public function getThemes(): array
+ {
+ return $this->themes;
+ }
+
+ public function getPhikiPropertyName(string $prefix, string $property): string
+ {
+ return "--phiki-{$prefix}-{$property}";
+ }
+
+ /**
+ * @param array $attributes
+ */
+ public static function toAttributeString(array $attributes): string
+ {
+ $attributeParts = [];
+
+ foreach ($attributes as $k => $v) {
+ $attributeParts[] = "{$k}='{$v}'";
+ }
+
+ return implode(' ', $attributeParts);
+ }
+
+ /**
+ * @param array $styles
+ */
+ public function toStyleString(array $styles): string
+ {
+ $styleParts = [];
+
+ foreach ($styles as $k => $v) {
+ if ($v === null) {
+ continue;
+ }
+
+ if (! is_string($k)) {
+ $styleParts[] = $v;
+
+ continue;
+ }
+
+ $styleParts[] = "{$k}: {$v}";
+ }
+
+ if (count($styleParts) === 0) {
+ return '';
+ }
+
+ return implode(';', $styleParts);
+ }
+
+ private function highlighter(): Highlighter
+ {
+ if ($this->highlighter === null) {
+ throw new LogicException('Theme highlighter has not been configured.');
+ }
+
+ return $this->highlighter;
+ }
+}
diff --git a/src/Generators/TokenMetadata.php b/src/Generators/TokenMetadata.php
new file mode 100644
index 0000000..9dc7a8c
--- /dev/null
+++ b/src/Generators/TokenMetadata.php
@@ -0,0 +1,64 @@
+ $classes
+ * @param array $attributes
+ */
+ public function __construct(
+ public array $classes = [],
+ public array $attributes = [],
+ public ?string $rawContent = null,
+ ) {}
+
+ public function hasClasses(): bool
+ {
+ return ! empty($this->classes);
+ }
+
+ public function hasAttributes(): bool
+ {
+ return ! empty($this->attributes);
+ }
+
+ public function isRaw(): bool
+ {
+ return $this->rawContent !== null;
+ }
+
+ public function addClass(string $class): static
+ {
+ $this->classes[] = $class;
+
+ return $this;
+ }
+
+ public function addAttribute(string $name, string $value): static
+ {
+ $this->attributes[$name] = $value;
+
+ return $this;
+ }
+
+ public function setRawContent(string $content): static
+ {
+ $this->rawContent = $content;
+
+ return $this;
+ }
+
+ public function merge(TokenMetadata $other): static
+ {
+ $this->classes = array_merge($this->classes, $other->classes);
+ $this->attributes = array_merge($this->attributes, $other->attributes);
+
+ if ($other->rawContent !== null) {
+ $this->rawContent = $other->rawContent;
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Generators/TokenTransformers/FileTreeTransformer.php b/src/Generators/TokenTransformers/FileTreeTransformer.php
new file mode 100644
index 0000000..1c2b87a
--- /dev/null
+++ b/src/Generators/TokenTransformers/FileTreeTransformer.php
@@ -0,0 +1,285 @@
+ */
+ protected array $commentScopes = [
+ 'source.files',
+ 'comment.line.number-sign.yaml',
+ 'punctuation.definition.comment.yaml',
+ ];
+
+ public function __construct(protected TreeConnectorGrid $connectorGrid = new TreeConnectorGrid) {}
+
+ public function setFileClassPrefix(string $prefix): static
+ {
+ $this->fileClassPrefix = $prefix;
+
+ return $this;
+ }
+
+ public function getFileClassPrefix(): string
+ {
+ return $this->fileClassPrefix;
+ }
+
+ public function setConnectorClassPrefix(string $prefix): static
+ {
+ $this->connectorClassPrefix = $prefix;
+
+ return $this;
+ }
+
+ public function getConnectorClassPrefix(): string
+ {
+ return $this->connectorClassPrefix;
+ }
+
+ public function setGrammarName(string $name): static
+ {
+ $this->grammarName = $name;
+
+ return $this;
+ }
+
+ public function getGrammarName(): string
+ {
+ return $this->grammarName;
+ }
+
+ /** @param list $scopes */
+ public function setCommentScopes(array $scopes): static
+ {
+ $this->commentScopes = $scopes;
+
+ return $this;
+ }
+
+ /** @return list */
+ public function getCommentScopes(): array
+ {
+ return $this->commentScopes;
+ }
+
+ public function supports(string $grammarName): bool
+ {
+ return $grammarName === $this->grammarName;
+ }
+
+ /**
+ * @param array> $tokens
+ * @return array>
+ */
+ public function transform(RenderContext $context, array $tokens): array
+ {
+ $lineInfo = $this->extractLineMetadata($tokens);
+ $lineInfo = $this->normalizeDepths($lineInfo);
+
+ $grid = $this->connectorGrid->build($lineInfo);
+
+ $commentSettings = $context->getScopeSettings($this->commentScopes);
+
+ $style = $context->options->fileStyle ?? 'ascii';
+
+ if ($style === 'ascii') {
+ return $this->renderAsciiConnectors($tokens, $grid, $lineInfo, $commentSettings);
+ }
+
+ return $this->renderHtmlConnectors($tokens, $grid, $lineInfo, $commentSettings);
+ }
+
+ /**
+ * @param array> $lines
+ * @return array
+ */
+ protected function extractLineMetadata(array $lines): array
+ {
+ $info = [];
+
+ foreach ($lines as $lineIndex => $tokens) {
+ $info[$lineIndex] = [
+ 'depth' => 0,
+ 'isCommentOnly' => false,
+ 'isDirectory' => false,
+ 'content' => '',
+ ];
+
+ if (empty($tokens)) {
+ continue;
+ }
+
+ /** @var RenderableToken $token */
+ foreach ($tokens as $token) {
+ $tokenText = $token->highlighted->token->text;
+
+ // Whitespace token determines depth
+ if (trim($tokenText) == '') {
+ $info[$lineIndex]['depth'] = mb_strlen($tokenText);
+
+ continue;
+ }
+
+ // Comment lines start with #
+ if (str_starts_with($tokenText, '#')) {
+ $info[$lineIndex]['isCommentOnly'] = true;
+ break;
+ }
+
+ // Directory entries end with /
+ $isDirectory = str_ends_with($tokenText, '/');
+ $info[$lineIndex]['isDirectory'] = $isDirectory;
+ $info[$lineIndex]['content'] = $tokenText;
+
+ // Set metadata on the token
+ $attributes = [];
+
+ if (! $isDirectory) {
+ $attributes['tl-file-extension'] = htmlspecialchars(
+ pathinfo($token->highlighted->token->text, PATHINFO_EXTENSION)
+ );
+ }
+
+ $token->metadata->classes = [
+ $isDirectory
+ ? "{$this->fileClassPrefix}-folder"
+ : "{$this->fileClassPrefix}-file",
+ "{$this->fileClassPrefix}-name",
+ ];
+ $token->metadata->attributes = $attributes;
+
+ break;
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * @param array $info
+ * @return array
+ */
+ protected function normalizeDepths(array $info): array
+ {
+ $allDepths = array_map(fn ($i) => $i['depth'], $info);
+ $uniqueDepths = array_unique($allDepths);
+ sort($uniqueDepths);
+
+ $levels = [];
+ foreach ($uniqueDepths as $i => $level) {
+ $levels[$level] = $i;
+ }
+
+ return array_map(function ($line) use ($levels) {
+ $levelIndex = $levels[$line['depth']];
+ $line['depth'] = $levelIndex * 3;
+
+ return $line;
+ }, $info);
+ }
+
+ /**
+ * @param array> $lines
+ * @param array> $grid
+ * @param array $lineInfo
+ * @param array $commentSettings
+ * @return array>
+ */
+ protected function renderAsciiConnectors(array $lines, array $grid, array $lineInfo, array $commentSettings): array
+ {
+ foreach ($lines as $lineIndex => $tokens) {
+ if (empty($tokens) || empty($grid[$lineIndex])) {
+ continue;
+ }
+
+ /** @var RenderableToken $token */
+ foreach ($tokens as $tokenIndex => $token) {
+ if (trim($token->highlighted->token->text) !== '') {
+ continue;
+ }
+
+ // Convert bitmask grid row to ASCII characters
+ $asciiContent = $this->connectorGrid->rowToAscii($grid[$lineIndex]);
+
+ // Update the token text
+ $token->highlighted->token->text = $asciiContent;
+
+ // Create new HighlightedToken with comment settings wrapped in RenderableToken
+ $newHighlighted = new HighlightedToken(
+ $token->highlighted->token,
+ $commentSettings,
+ );
+
+ $lines[$lineIndex][$tokenIndex] = new RenderableToken(
+ $newHighlighted,
+ $token->metadata,
+ );
+
+ break;
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * @param array> $lines
+ * @param array> $grid
+ * @param array $lineInfo
+ * @param array $commentSettings
+ * @return array>
+ */
+ protected function renderHtmlConnectors(array $lines, array $grid, array $lineInfo, array $commentSettings): array
+ {
+ foreach ($lines as $lineIndex => $tokens) {
+ if (empty($tokens) || empty($grid[$lineIndex])) {
+ continue;
+ }
+
+ /** @var RenderableToken $token */
+ foreach ($tokens as $tokenIndex => $token) {
+ if (trim($token->highlighted->token->text) !== '') {
+ continue;
+ }
+
+ // Convert bitmask grid row to HTML
+ $rawContent = $this->connectorGrid->rowToHtml(
+ $grid[$lineIndex],
+ $this->connectorClassPrefix
+ );
+
+ // Update the token text
+ $token->highlighted->token->text = $rawContent;
+
+ // Create new HighlightedToken with comment settings
+ $newHighlighted = new HighlightedToken(
+ $token->highlighted->token,
+ $commentSettings,
+ );
+
+ // Replace with new RenderableToken with raw content metadata
+ $lines[$lineIndex][$tokenIndex] = new RenderableToken(
+ $newHighlighted,
+ $token->metadata->setRawContent($rawContent),
+ );
+
+ break;
+ }
+ }
+
+ return $lines;
+ }
+}
diff --git a/src/Generators/TokenTransformers/IndentGuideTransformer.php b/src/Generators/TokenTransformers/IndentGuideTransformer.php
new file mode 100644
index 0000000..bbb207c
--- /dev/null
+++ b/src/Generators/TokenTransformers/IndentGuideTransformer.php
@@ -0,0 +1,275 @@
+classPrefix = $prefix;
+
+ return $this;
+ }
+
+ public function getClassPrefix(): string
+ {
+ return $this->classPrefix;
+ }
+
+ public function supports(string $grammarName): bool
+ {
+ return $grammarName !== 'files';
+ }
+
+ /**
+ * @param array