@@ -595,12 +595,251 @@ public function onShortcodeHandlers(Event $e): void
595595 }
596596
597597 /**
598- * Add CSS and JS assets
598+ * Add CSS and JS assets and Twig extensions
599599 */
600600 public function onTwigSiteVariables (): void
601601 {
602602 $ this ->grav ['assets ' ]->addCss ('plugin://codesh/css/codesh.css ' );
603603 $ this ->grav ['assets ' ]->addJs ('plugin://codesh/js/codesh.js ' , ['group ' => 'bottom ' , 'defer ' => true ]);
604+
605+ // Add Twig filter for use in templates
606+ $ twig = $ this ->grav ['twig ' ]->twig ();
607+ $ twig ->addFilter (new \Twig \TwigFilter ('codesh ' , [$ this , 'codeshFilter ' ], ['is_safe ' => ['html ' ]]));
608+ }
609+
610+ /**
611+ * Twig filter to highlight code using codesh
612+ *
613+ * Usage in templates:
614+ * {{ code_string|codesh('json') }}
615+ * {{ code_string|codesh('php', {title: 'example.php', 'line-numbers': true}) }}
616+ *
617+ * @param string $content The code to highlight
618+ * @param string $lang The language (default: 'txt')
619+ * @param array $options Options: theme, line-numbers, start, highlight, focus, class, show-lang, title, header
620+ * @return string The highlighted HTML
621+ */
622+ public function codeshFilter (string $ content , string $ lang = 'txt ' , array $ options = []): string
623+ {
624+ $ mergedOptions = array_merge ([
625+ 'theme ' => $ options ['theme ' ] ?? null ,
626+ 'line-numbers ' => $ options ['line-numbers ' ] ?? false ,
627+ 'start ' => $ options ['start ' ] ?? 1 ,
628+ 'highlight ' => $ options ['highlight ' ] ?? '' ,
629+ 'focus ' => $ options ['focus ' ] ?? '' ,
630+ 'class ' => $ options ['class ' ] ?? '' ,
631+ 'show-lang ' => $ options ['show-lang ' ] ?? true ,
632+ 'title ' => $ options ['title ' ] ?? '' ,
633+ 'header ' => $ options ['header ' ] ?? true ,
634+ ], $ options );
635+
636+ // Generate cache key based on content, language, options, and theme
637+ $ themeConfig = $ this ->config ->get ('themes.helios.appearance.theme ' , 'system ' );
638+ $ cacheKey = 'codesh_ ' . md5 ($ content . $ lang . serialize ($ mergedOptions ) . $ themeConfig );
639+
640+ // Try to get from cache
641+ $ cache = $ this ->grav ['cache ' ];
642+ $ cached = $ cache ->fetch ($ cacheKey );
643+
644+ if ($ cached !== false ) {
645+ return $ cached ;
646+ }
647+
648+ // Generate highlighted code
649+ $ result = $ this ->highlightCodeFull ($ content , $ lang , $ mergedOptions );
650+
651+ // Cache the result (1 hour TTL)
652+ $ cache ->save ($ cacheKey , $ result , 3600 );
653+
654+ return $ result ;
655+ }
656+
657+ /**
658+ * Highlight code using Phiki (shared implementation for filter and page processing)
659+ */
660+ protected function highlightCodeFull (string $ code , string $ lang , array $ options ): string
661+ {
662+ $ config = $ this ->config ->get ('plugins.codesh ' );
663+
664+ // Detect theme mode from Helios theme config
665+ $ themeConfig = $ this ->config ->get ('themes.helios.appearance.theme ' , 'system ' );
666+
667+ // Use custom helios themes by default (with diff backgrounds)
668+ $ themeDark = $ config ['theme_dark ' ] ?? 'helios-dark ' ;
669+ $ themeLight = $ config ['theme_light ' ] ?? 'helios-light ' ;
670+
671+ // Get theme - explicit theme parameter overrides mode-based themes
672+ $ explicitTheme = $ options ['theme ' ] ?? null ;
673+ if ($ explicitTheme ) {
674+ $ theme = $ explicitTheme ;
675+ } elseif ($ themeConfig === 'system ' ) {
676+ $ theme = [
677+ 'light ' => $ themeLight ,
678+ 'dark ' => $ themeDark ,
679+ ];
680+ } else {
681+ $ theme = ($ themeConfig === 'dark ' ) ? $ themeDark : $ themeLight ;
682+ }
683+
684+ $ lineNumbers = $ this ->toBool ($ options ['line-numbers ' ] ?? $ config ['show_line_numbers ' ] ?? false );
685+ $ startLine = (int ) ($ options ['start ' ] ?? 1 );
686+ $ highlight = $ options ['highlight ' ] ?? '' ;
687+ $ focus = $ options ['focus ' ] ?? '' ;
688+ $ class = $ options ['class ' ] ?? '' ;
689+ $ showLang = $ this ->toBool ($ options ['show-lang ' ] ?? true );
690+ $ title = $ options ['title ' ] ?? '' ;
691+ $ showHeader = $ this ->toBool ($ options ['header ' ] ?? true );
692+
693+ // Clean up the content
694+ $ code = html_entity_decode ($ code , ENT_QUOTES | ENT_HTML5 , 'UTF-8 ' );
695+ $ code = trim ($ code , "\n\r" );
696+
697+ if (empty (trim ($ code ))) {
698+ return '' ;
699+ }
700+
701+ try {
702+ $ phiki = $ this ->getPhiki ();
703+ $ output = $ phiki ->codeToHtml ($ code , strtolower ($ lang ), $ theme );
704+
705+ // Add 'no-highlight' class to prevent Prism.js from reprocessing
706+ $ output = $ output ->decoration (
707+ PreDecoration::make ()->class ('no-highlight ' )
708+ );
709+
710+ // Add line numbers if enabled
711+ if ($ lineNumbers ) {
712+ $ output = $ output ->withGutter ();
713+ if ($ startLine !== 1 ) {
714+ $ output = $ output ->startingLine ($ startLine );
715+ }
716+ }
717+
718+ // Add line decorations for highlighting
719+ if (!empty ($ highlight )) {
720+ $ highlightLines = $ this ->parseLineSpec ($ highlight );
721+ foreach ($ highlightLines as $ line ) {
722+ $ output = $ output ->decoration (
723+ \Phiki \Transformers \Decorations \LineDecoration::forLine ($ line - 1 )->class ('highlight ' )
724+ );
725+ }
726+ }
727+
728+ // Add line decorations for focus
729+ if (!empty ($ focus )) {
730+ $ focusLines = $ this ->parseLineSpec ($ focus );
731+ foreach ($ focusLines as $ line ) {
732+ $ output = $ output ->decoration (
733+ \Phiki \Transformers \Decorations \LineDecoration::forLine ($ line - 1 )->class ('focus ' )
734+ );
735+ }
736+ }
737+
738+ $ html = $ output ->toString ();
739+
740+ // Wrap in container with optional class
741+ $ classes = ['codesh-block ' ];
742+ if (is_array ($ theme )) {
743+ $ classes [] = 'codesh-dual-theme ' ;
744+ }
745+ if (!empty ($ class )) {
746+ $ classes [] = htmlspecialchars ($ class );
747+ }
748+ if (!empty ($ highlight )) {
749+ $ classes [] = 'has-highlights ' ;
750+ }
751+ if (!empty ($ focus )) {
752+ $ classes [] = 'has-focus ' ;
753+ }
754+ if (!$ showHeader ) {
755+ $ classes [] = 'no-header ' ;
756+ }
757+
758+ // Build the complete HTML output
759+ $ result = '<div class=" ' . implode (' ' , $ classes ) . '" data-language=" ' . htmlspecialchars ($ lang ) . '"> ' ;
760+
761+ // Add header with language/title and copy button
762+ if ($ showHeader ) {
763+ $ result .= '<div class="codesh-header"> ' ;
764+
765+ // Display title or language
766+ if (!empty ($ title )) {
767+ $ result .= '<span class="codesh-title"> ' . htmlspecialchars ($ title ) . '</span> ' ;
768+ } elseif ($ showLang && !empty ($ lang )) {
769+ $ result .= '<span class="codesh-lang"> ' . htmlspecialchars (strtoupper ($ lang )) . '</span> ' ;
770+ } else {
771+ $ result .= '<span class="codesh-lang"></span> ' ;
772+ }
773+
774+ // Copy button
775+ $ result .= '<button class="codesh-copy" type="button" title="Copy code"> ' ;
776+ $ result .= '<svg class="codesh-copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> ' ;
777+ $ result .= '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/> ' ;
778+ $ result .= '</svg> ' ;
779+ $ result .= '<span class="codesh-copy-text">Copy</span> ' ;
780+ $ result .= '</button> ' ;
781+
782+ $ result .= '</div> ' ;
783+ }
784+
785+ // Add the code
786+ $ result .= '<div class="codesh-code"> ' . $ html . '</div> ' ;
787+ $ result .= '</div> ' ;
788+
789+ return $ result ;
790+
791+ } catch (\Exception $ e ) {
792+ // Fallback to plain text on error
793+ return '<div class="codesh-block codesh-error" data-error=" ' . htmlspecialchars ($ e ->getMessage ()) . '"><pre><code> ' . htmlspecialchars ($ code ) . '</code></pre></div> ' ;
794+ }
795+ }
796+
797+ /**
798+ * Parse line specification like "1,3-5,7" into array of line numbers
799+ */
800+ protected function parseLineSpec (string $ spec ): array
801+ {
802+ $ lines = [];
803+ $ parts = explode (', ' , $ spec );
804+
805+ foreach ($ parts as $ part ) {
806+ $ part = trim ($ part );
807+ if (empty ($ part )) {
808+ continue ;
809+ }
810+
811+ if (str_contains ($ part , '- ' )) {
812+ [$ start , $ end ] = explode ('- ' , $ part , 2 );
813+ $ start = (int ) trim ($ start );
814+ $ end = (int ) trim ($ end );
815+ if ($ start > 0 && $ end >= $ start ) {
816+ for ($ i = $ start ; $ i <= $ end ; $ i ++) {
817+ $ lines [] = $ i ;
818+ }
819+ }
820+ } else {
821+ $ lineNum = (int ) $ part ;
822+ if ($ lineNum > 0 ) {
823+ $ lines [] = $ lineNum ;
824+ }
825+ }
826+ }
827+
828+ return array_unique ($ lines );
829+ }
830+
831+ /**
832+ * Convert various values to boolean
833+ */
834+ protected function toBool ($ value ): bool
835+ {
836+ if (is_bool ($ value )) {
837+ return $ value ;
838+ }
839+ if (is_string ($ value )) {
840+ return in_array (strtolower ($ value ), ['true ' , '1 ' , 'yes ' , 'on ' ], true );
841+ }
842+ return (bool ) $ value ;
604843 }
605844
606845 /**
0 commit comments