diff --git a/.gitignore b/.gitignore index ee82a6f..7e9e61a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /gems.locked /.covered.db /external + +/node_modules diff --git a/gems.rb b/gems.rb index 8497a4e..77a2785 100644 --- a/gems.rb +++ b/gems.rb @@ -14,6 +14,7 @@ gem "agent-context" + gem "utopia" gem "utopia-project" gem "decode" diff --git a/lib/luna.rb b/lib/luna.rb index 151cf0e..440b359 100644 --- a/lib/luna.rb +++ b/lib/luna.rb @@ -6,4 +6,6 @@ require_relative "luna/version" module Luna + # The root directory for Luna's own assets, e.g. stylesheets and scripts. + PUBLIC_ROOT = File.expand_path("../public", __dir__) end diff --git a/lib/luna/command/serve.rb b/lib/luna/command/serve.rb index dfce281..2e9cea9 100644 --- a/lib/luna/command/serve.rb +++ b/lib/luna/command/serve.rb @@ -24,6 +24,7 @@ class Serve < Samovar::Command option "-i/--index ", "Directory index", default: "index.html" option "--[no]-directory-listing", "Enable directory listing", default: true option "--[no]-markdown", "Enable Markdown rendering", default: true + option "--[no]-syntax-highlighting", "Enable syntax highlighting", default: true option "-v/--[no]-verbose", "Verbose logging", default: false end @@ -55,7 +56,8 @@ def middleware verbose: @options[:verbose], markdown: @options[:markdown], index: @options[:index], - directory_listing: @options[:directory_listing] + directory_listing: @options[:directory_listing], + syntax_highlighting: @options[:syntax_highlighting] ) end @@ -64,7 +66,7 @@ def call buffer.puts "Luna v#{Luna::VERSION} serving..." buffer.puts "- Root: #{File.expand_path(root_directory)}" buffer.puts "- Bind: #{endpoint}" - buffer.puts "- Markdown: #{@options[:markdown]} | Index: #{@options[:index]} | Directory Listing: #{@options[:directory_listing]}" + buffer.puts "- Markdown: #{@options[:markdown]} | Index: #{@options[:index]} | Directory Listing: #{@options[:directory_listing]} | Syntax Highlighting: #{@options[:syntax_highlighting]}" end Async do |task| diff --git a/lib/luna/document.rb b/lib/luna/document.rb new file mode 100644 index 0000000..4bbf2b0 --- /dev/null +++ b/lib/luna/document.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +module Luna + # Wraps rendered content in a minimal HTML document. + # + # Both the Markdown renderer and the directory listing use this, so that they + # agree on the document structure and share the same stylesheet. + class Document + # @parameter stylesheet [String | Nil] The stylesheet to link, if any. + # @parameter scripts [Array(String)] Module scripts to include. + def initialize(stylesheet: "/_static/luna.css", scripts: []) + @stylesheet = stylesheet + @scripts = scripts + end + + # @attribute [String | Nil] The stylesheet to link, if any. + attr :stylesheet + + # @attribute [Array(String)] Module scripts to include. + attr :scripts + + # @parameter title [String] The title of the document. + # @parameter body [String] The rendered content. + # @returns [String] A complete HTML document. + def call(title, body) + <<~HTML + + + + #{escape_html(title)} + #{head.join("\n")} + #{body} + HTML + end + + private + + def head + links = [] + + if @stylesheet + links << "" + end + + @scripts.each do |script| + links << "" + end + + links + end + + def escape_html(text) + text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """) + end + end +end diff --git a/lib/luna/middleware/markdown.rb b/lib/luna/middleware/markdown.rb index 10bc73a..48a0e1b 100644 --- a/lib/luna/middleware/markdown.rb +++ b/lib/luna/middleware/markdown.rb @@ -5,6 +5,8 @@ require "protocol/http/middleware" +require_relative "../document" + begin require "markly" rescue LoadError @@ -16,14 +18,19 @@ module Middleware # Render Markdown files to HTML on-the-fly. # Intercepts requests that target .md/.markdown files. class Markdown < Protocol::HTTP::Middleware - def initialize(app, root: Dir.pwd, index_candidates: ["index.md", "README.md"]) + # The GitHub Flavored Markdown extensions, which are not enabled by default. + EXTENSIONS = [:table, :strikethrough, :autolink, :tagfilter, :tasklist].freeze + + def initialize(app, root: Dir.pwd, index_candidates: ["index.md", "README.md"], document: Document.new) super(app) @root = File.expand_path(root) @index_candidates = index_candidates + @document = document end attr :root attr :index_candidates + attr :document def safe_join(path) full = File.expand_path(File.join(@root, path)) @@ -56,7 +63,7 @@ def markdown_file?(path) def render_html(markdown) if defined?(::Markly) - ::Markly.render_html(markdown) + ::Markly.render_html(markdown, extensions: EXTENSIONS) else # Fallback minimal rendering if markly isn't available: escape = ->(s){s.to_s.gsub("&","&").gsub("<","<").gsub(">",">")} @@ -66,7 +73,7 @@ def render_html(markdown) def render_file(path, head: false) content = File.read(path) - html = render_html(content) + html = @document.call(File.basename(path), render_html(content)) headers = [ ["content-type", "text/html; charset=utf-8"] ] diff --git a/lib/luna/middleware/static.rb b/lib/luna/middleware/static.rb index bc214c9..f8892cd 100644 --- a/lib/luna/middleware/static.rb +++ b/lib/luna/middleware/static.rb @@ -7,6 +7,8 @@ require "protocol/http/body/file" require "time" +require_relative "../document" + module Luna module Middleware # Serve static files from a root directory, with optional index and directory listing. @@ -29,16 +31,18 @@ class Static < Protocol::HTTP::Middleware ".zip" => "application/zip", }.freeze - def initialize(app, root: Dir.pwd, index: "index.html", directory_listing: true) + def initialize(app, root: Dir.pwd, index: "index.html", directory_listing: true, document: Document.new) super(app) @root = File.expand_path(root) @index = index @directory_listing = directory_listing + @document = document end attr :root attr :index attr :directory_listing + attr :document def mime_type_for_extension(ext) MIME_TYPES[ext.downcase] || "application/octet-stream" @@ -99,13 +103,8 @@ def serve_directory_listing(dir_path, request_path) target += "/" if File.directory?(File.join(dir_path, e)) && !target.end_with?("/") "
  • #{escape_html(name)}
  • " end.join("\n") - html = <<~HTML - - - Index of #{escape_html(request_path)} -

    Index of #{escape_html(request_path)}

    - - HTML + title = "Index of #{request_path}" + html = @document.call(title, "

    #{escape_html(title)}

    \n") headers = [ ["content-type", "text/html; charset=utf-8"] diff --git a/lib/luna/server.rb b/lib/luna/server.rb index 5ae33c4..170e0f5 100644 --- a/lib/luna/server.rb +++ b/lib/luna/server.rb @@ -7,6 +7,8 @@ require "protocol/http/middleware/builder" require "protocol/http/content_encoding" +require_relative "../luna" +require_relative "document" require_relative "middleware/verbose" require_relative "middleware/static" require_relative "middleware/markdown" @@ -20,16 +22,26 @@ class Server < Async::HTTP::Server # @param markdown [Boolean] Enable markdown rendering. # @param index [String] Index file for directories. # @param directory_listing [Boolean] Enable basic directory listing when no index exists. - def self.middleware(root: Dir.pwd, verbose: false, markdown: true, index: "index.html", directory_listing: true) + # @param syntax_highlighting [Boolean] Highlight code using Luna's bundled syntax highlighter. + def self.middleware(root: Dir.pwd, verbose: false, markdown: true, index: "index.html", directory_listing: true, syntax_highlighting: true) + scripts = syntax_highlighting ? ["/_static/application.js"] : [] + document = Luna::Document.new(scripts: scripts) + ::Protocol::HTTP::Middleware.build do use Luna::Middleware::Verbose if verbose use ::Protocol::HTTP::ContentEncoding - use Luna::Middleware::Markdown, root: root if markdown - run Luna::Middleware::Static.new( - ->(request){Protocol::HTTP::Response[404, {"content-type"=>"text/plain"}, ["Not Found"]]}, + use Luna::Middleware::Markdown, root: root, document: document if markdown + use Luna::Middleware::Static, root: root, index: index, - directory_listing: directory_listing + directory_listing: directory_listing, + document: document + + # Luna's own assets are served last, so that files in the root take precedence: + run Luna::Middleware::Static.new( + ->(request){Protocol::HTTP::Response[404, {"content-type"=>"text/plain"}, ["Not Found"]]}, + root: Luna::PUBLIC_ROOT, + directory_listing: false ) end end diff --git a/luna.gemspec b/luna.gemspec index 74d4af6..06f6166 100644 --- a/luna.gemspec +++ b/luna.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |spec| "source_code_uri" => "https://github.com/socketry/luna.git", } - spec.files = Dir.glob(["{bin,examples,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) + spec.files = Dir.glob(["{bin,examples,lib,public}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.executables = ["luna"] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e93934 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "luna", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@socketry/syntax": "^0.6.1" + } + }, + "node_modules/@socketry/syntax": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@socketry/syntax/-/syntax-0.6.1.tgz", + "integrity": "sha512-PFhYoPxKxB9YJorfcOx92++tKpiET8KpuxHbo9VbcAj9bINeVYCkAsMNgbswnxf9whLNZsAj5FCqt0I8oGAe2g==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7f9a9f3 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@socketry/syntax": "^0.6.1" + } +} diff --git a/public/_components/@socketry/syntax/Syntax.js b/public/_components/@socketry/syntax/Syntax.js new file mode 100644 index 0000000..b3a1b5d --- /dev/null +++ b/public/_components/@socketry/syntax/Syntax.js @@ -0,0 +1,356 @@ +/** + * Syntax - Core highlighting engine + * A modern, framework-agnostic syntax highlighter + * + * @package @socketry/syntax + * @author Samuel G. D. Williams + * @license MIT + */ + +import {Loader} from './Syntax/Loader.js'; +import { + LanguageNotFoundError, + LanguageLoadError, + StyleSheetLoadError +} from './Syntax/Errors.js'; + +export class Syntax { + static #default = null; + + #root = null; + #aliases = {}; + #languages = new Loader((loader, name) => this.#loadLanguage(loader, name)); + #styleSheets = new Loader((loader, url) => this.#loadStyleSheet(loader, url)); + #styles = {}; + #themes = {}; + #themeRoot = null; // Base URL for theme assets (CSS) + + #defaultOptions = { + theme: 'base', + linkify: true, + strict: false + }; + + /** + * Get or create the default Syntax instance + */ + static get default() { + if (!this.#default) { + this.#default = new Syntax(); + } + return this.#default; + } + + /** + * Set a custom default Syntax instance + */ + static set default(instance) { + this.#default = instance; + } + + /** + * Detect the default root path for loading language modules + * Uses import.meta.url to reliably locate the Syntax module directory + */ + static detectRoot() { + try { + const url = new URL('./', import.meta.url); + return url.href; + } catch (error) { + // Fallback: try document.currentScript (may not work reliably) + if ( + typeof document !== 'undefined' && + document.currentScript && + document.currentScript.src + ) { + const url = new URL(document.currentScript.src); + return url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1); + } + // Last resort fallback + return '/'; + } + } + + /** + * Initialize syntax highlighting on the page + * Registers the web component and sets up the default syntax instance + * Languages will be auto-loaded on demand when referenced by elements + * + * @param {Object} options - Configuration options + * @param {Syntax} options.syntax - Syntax instance to use (defaults to Syntax.default) + * @param {boolean} options.upgradeAll - Whether to automatically upgrade existing elements (default: true) + * @param {string} options.selector - CSS selector for upgrading (default: 'code[class*="language-"]') + * @param {string} options.root - Base URL for loading language modules (default: auto-detected) + * @returns {Promise} + */ + static async highlight(options = {}) { + const { + syntax = Syntax.default, + upgradeAll: shouldUpgradeAll = true, + selector = 'code[class*="language-"]', + root = null + } = options; + + // Set the default syntax instance: + Syntax.default = syntax; + + // Configure root for auto-loading if provided: + if (root && !syntax.root) { + syntax.root = root; + } + + // Import and register the web component: + const {CodeElement, upgradeAll} = await import('./Syntax/CodeElement.js'); + + if (!customElements.get('syntax-code')) { + customElements.define('syntax-code', CodeElement); + } + + // Upgrade existing code blocks if requested: + if (shouldUpgradeAll) { + if (upgradeAll) { + // Use the upgradeAll function with the selector: + upgradeAll(selector, syntax); + } else { + // Fallback to customElements.upgrade if the function isn't available: + customElements.upgrade(document.body); + } + } + } + + constructor(options = {}) { + // Allow customization via constructor options + if (options.root !== undefined) this.#root = options.root; + if (options.theme !== undefined) this.#defaultOptions.theme = options.theme; + if (options.themeRoot !== undefined) this.#themeRoot = options.themeRoot; + + // Set default root if not provided + if (this.#root === null) { + this.#root = Syntax.detectRoot(); + } + } + + // Public getters for commonly accessed properties + get defaultOptions() { + return this.#defaultOptions; + } + + get languages() { + return this.#languages; + } + + get aliases() { + return this.#aliases; + } + + get styles() { + return this.#styles; + } + + get themes() { + return this.#themes; + } + + // Theme root for CSS assets + get themeRoot() { + return this.#resolveThemeRoot().toString(); + } + + set themeRoot(value) { + this.#themeRoot = value; + // Clear the stylesheet cache when theme changes + this.#styleSheets.clear(); + } + + get root() { + return this.#root; + } + + set root(value) { + this.#root = value; + } + + /** + * Resolve the base URL for the current theme's assets. + * Priority: + * 1) Explicit options.themeRoot or setter + * 2) Relative to this module location: ./themes// + */ + #resolveThemeRoot() { + try { + if (this.#themeRoot) { + return new URL( + this.#themeRoot, + typeof document !== 'undefined' ? document.baseURI : import.meta.url + ); + } + // Default to a folder next to Syntax.js + return new URL(`./themes/${this.#defaultOptions.theme}/`, import.meta.url); + } catch (error) { + // As a last resort, fall back to root if provided or current location: + const base = + this.#root || (typeof location !== 'undefined' ? location.href : ''); + return new URL(`themes/${this.#defaultOptions.theme}/`, base); + } + } + + /** + * Fetch CSS for use in Shadow DOM + * Returns an object with {sheet, cssText} where sheet is a CSSStyleSheet (if supported) + * The result is cached and deduplicated across calls + */ + async getStyleSheet(url) { + return this.#styleSheets.load(url); + } + + /** + * Load a stylesheet from a URL + * Used internally by the stylesheet loader + */ + async #loadStyleSheet(loader, url) { + const response = await fetch(url); + if (!response.ok) { + throw new StyleSheetLoadError(url.toString(), response.status); + } + const cssText = await response.text(); + + // If CSSStyleSheet constructor is available, create and return a stylesheet: + if (typeof CSSStyleSheet !== 'undefined') { + const sheet = new CSSStyleSheet(); + await sheet.replace(cssText); + return {sheet, cssText}; + } + + // Otherwise just return the text: + return {cssText}; + } + + /** + * Load a language module from disk/network + * Used internally by the language loader + */ + async #loadLanguage(loader, name) { + const path = `${this.#root}Syntax/Language/${name}.js`; + let module; + try { + module = await import(path); + } catch (error) { + throw new LanguageLoadError(name, path, {cause: error}); + } + + // If the module exports a register function, call it with this instance + if (typeof module.default === 'function') { + module.default(this); + } + + // After calling register, aliases have been registered. Re-resolve the name: + let resolvedName = this.#aliases[name] || name; + return loader.get(resolvedName); + } + + /** + * Load a language module dynamically + */ + async getResource(name) { + // First check if the language is already loaded (including via alias) + const resolvedName = this.#aliases[name] || name; + if (this.#languages.has(resolvedName)) { + return this.#languages.get(resolvedName); + } + + // Use the loader to deduplicate concurrent loads + return this.#languages.load(resolvedName); + } + + /** + * Register language aliases + */ + alias(name, aliases) { + this.#aliases[name] = name; + + for (const alias of aliases) { + this.#aliases[alias] = name; + } + } + + /** + * Register a language with this Syntax instance + */ + register(name, language) { + // Store directly in the loader's cache using the new set() method + this.#languages.set(name, language); + + // Also store in aliases if not already there + if (!this.#aliases[name]) { + this.#aliases[name] = name; + } + + return language; + } + + /** + * Get a language by name or alias + * Auto-loads the language if not already registered + */ + async getLanguage(name) { + // Resolve alias + const resolvedName = (this.#aliases[name] || name).toLowerCase(); + + // If already loaded, return it + if (this.#languages.has(resolvedName)) { + return this.#languages.get(resolvedName); + } + + // Otherwise, try to load it + try { + return await this.getResource(name); + } catch (error) { + if (this.#defaultOptions.strict && !(error instanceof LanguageLoadError)) { + // If strict and not a load error, ensure a consistent error type: + throw new LanguageNotFoundError(resolvedName, {cause: error}); + } + throw error; + } + } + + /** + * Check if a language is already registered (synchronous) + */ + hasLanguage(name) { + // Resolve alias + const resolvedName = this.#aliases[name] || name; + return this.#languages.has(resolvedName); + } + + /** + * Get all aliases for a language + */ + languageAliases(language) { + const aliases = []; + + for (const [name, target] of Object.entries(this.#aliases)) { + if (target === language) { + aliases.push(name); + } + } + + return aliases; + } + + /** + * Get all language names (primary names, not aliases) + */ + languageNames() { + const names = []; + + for (const [name, target] of Object.entries(this.#aliases)) { + if (name === target) { + names.push(name); + } + } + + return names; + } +} + +export default Syntax; diff --git a/public/_components/@socketry/syntax/Syntax/CodeElement.js b/public/_components/@socketry/syntax/Syntax/CodeElement.js new file mode 100644 index 0000000..4c8de1e --- /dev/null +++ b/public/_components/@socketry/syntax/Syntax/CodeElement.js @@ -0,0 +1,416 @@ +import Syntax from '../Syntax.js'; +import {Match} from './Match.js'; + +const supportsAdopted = + typeof CSSStyleSheet !== 'undefined' && + 'adoptedStyleSheets' in Document.prototype; + +// These values are defined by the DOM standard. Keep them local so this code +// does not depend on a global `Node`, which may be unavailable in non-browser +// DOM implementations. +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const CDATA_SECTION_NODE = 4; + +/** + * Extract the source text and existing markup as source-aligned matches. + * + * The highlighting pipeline can then insert these matches into the syntax + * tree, preserving elements such as links while allowing their contents to + * receive syntax highlighting. + */ +function extractCode(root) { + let text = ''; + const matches = []; + + function extract(node) { + if (node.nodeType === TEXT_NODE || node.nodeType === CDATA_SECTION_NODE) { + text += node.nodeValue.replace(/\r/g, ''); + return; + } + + if (node.nodeType !== ELEMENT_NODE) { + return; + } + + if (node.tagName === 'BR') { + text += '\n'; + return; + } + + const offset = text.length; + let match = null; + + if (node !== root) { + match = new Match(offset, 0, {element: node, force: true, allow: '*'}, ''); + matches.push(match); + } + + for (const child of node.childNodes) { + extract(child); + } + + if (match) { + match.length = text.length - offset; + match.endOffset = text.length; + match.value = text.slice(offset); + } + } + + extract(root); + + return {text, matches: matches.filter(match => match.length > 0)}; +} + +/** + * CodeElement - Web Component for syntax highlighting with isolated styles + * + * Usage: + * const x = 1; + *
    puts "Hello"
    + */ +export class CodeElement extends HTMLElement { + static get observedAttributes() { + return ['language', 'theme', 'wrap']; + } + + #syntax = null; + #shadow; + #slot = null; + #rendered = null; + #adoptedHrefs = new Set(); + #highlighted = false; + + constructor() { + super(); + + /** + * A promise that resolves when the current highlighting attempt completes. + * Check `highlighted` before using line measurement APIs. + * @type {Promise} + */ + this.ready = Promise.resolve(); + } + + get syntax() { + return this.#syntax || Syntax.default; + } + + set syntax(value) { + this.#syntax = value; + // Re-render with new syntax instance if already connected: + if (this.isConnected && !this.#highlighted) { + this.ready = this.#render(); + } + } + + get language() { + return ( + this.getAttribute('language') || + this.#detectLanguageFromClass() + ); + } + + set language(value) { + if (value == null) { + this.removeAttribute('language'); + } else { + this.setAttribute('language', value); + } + } + + get theme() { + return this.getAttribute('theme') || this.syntax.defaultOptions.theme; + } + + set theme(value) { + if (value == null) { + this.removeAttribute('theme'); + } else { + this.setAttribute('theme', value); + } + } + + get wrap() { + return this.hasAttribute('wrap'); + } + + set wrap(value) { + if (value) { + this.setAttribute('wrap', ''); + } else { + this.removeAttribute('wrap'); + } + } + + /** + * Get the bounding client rect of a specific line (1-based). + * @param {number} lineNumber - The 1-based line number. + * @returns {DOMRect|null} The bounding rect, or null if not found. + */ + getLineBoundingClientRect(lineNumber) { + if (!this.#shadow) return null; + + const code = this.#shadow.querySelector('code'); + if (!code) return null; + + const lines = code.children; + if (lineNumber < 1 || lineNumber > lines.length) return null; + + return lines[lineNumber - 1].getBoundingClientRect(); + } + + /** + * Get the total number of rendered lines. + * @returns {number} The line count, or 0 if not yet rendered. + */ + get lineCount() { + if (!this.#shadow) return 0; + + const code = this.#shadow.querySelector('code'); + if (!code) return 0; + + return code.children.length; + } + + /** + * Whether the current highlighting attempt has completed successfully. + * @returns {boolean} + */ + get highlighted() { + return this.#highlighted; + } + + connectedCallback() { + // Detect if we're inside a
     element and set wrap attribute
    +		if (this.parentElement?.tagName === 'PRE') {
    +			this.wrap = true;
    +		}
    +
    +		// Don't re-highlight if already done
    +		if (this.#highlighted) {
    +			return;
    +		}
    +
    +		if (!this.#shadow) {
    +			this.#shadow = this.attachShadow({mode: 'open'});
    +			this.#slot = document.createElement('slot');
    +			this.#shadow.appendChild(this.#slot);
    +		}
    +
    +		this.ready = this.#render();
    +	}
    +
    +	attributeChangedCallback(name, oldValue, newValue) {
    +		if (oldValue === newValue) {
    +			return;
    +		}
    +
    +		if (
    +			(name === 'language' || name === 'theme' || name === 'wrap') &&
    +			this.isConnected &&
    +			this.#shadow
    +		) {
    +			// Reset highlighted state and track the new render attempt:
    +			this.#highlighted = false;
    +			this.#adoptedHrefs.clear();
    +			this.ready = this.#render();
    +		}
    +	}
    +
    +	/**
    +	 * Detect language from class names (e.g., language-javascript, brush-ruby)
    +	 */
    +	#detectLanguageFromClass() {
    +		const classes = this.className.split(/\s+/);
    +
    +		for (const cls of classes) {
    +			// Match language-* or brush-* patterns
    +			const match = cls.match(/^(?:language|brush)-(.+)$/);
    +			if (match) {
    +				return match[1];
    +			}
    +		}
    +
    +		return null;
    +	}
    +
    +	/**
    +	 * Get the source text and existing markup to highlight.
    +	 */
    +	#getCodeContent() {
    +		// Check if there's a  child element
    +		const codeElement = this.querySelector('code');
    +		if (codeElement) {
    +			return extractCode(codeElement);
    +		}
    +
    +		return extractCode(this);
    +	}
    +
    +	/**
    +	 * Load theme CSS into shadow root
    +	 */
    +	async #loadStylesheets(languageName) {
    +		// Guard: ensure shadow root exists
    +		if (!this.#shadow) {
    +			return;
    +		}
    +
    +		const themeRoot = new URL(
    +			this.syntax.themeRoot,
    +			typeof document !== 'undefined' ? document.baseURI : import.meta.url
    +		);
    +
    +		const urls = [
    +			new URL('syntax.css', themeRoot),
    +			new URL(`${languageName}.css`, themeRoot)
    +		];
    +
    +		if (supportsAdopted && this.#shadow.adoptedStyleSheets !== undefined) {
    +			const sheets = Array.from(this.#shadow.adoptedStyleSheets);
    +			for (const url of urls) {
    +				const href = url.toString();
    +				if (this.#adoptedHrefs.has(href)) continue;
    +				try {
    +					const result = await this.syntax.getStyleSheet(url);
    +					if (result.sheet) {
    +						sheets.push(result.sheet);
    +						this.#adoptedHrefs.add(href);
    +					}
    +				} catch (error) {
    +					console.warn(`Failed to load ${href}:`, error);
    +				}
    +			}
    +			this.#shadow.adoptedStyleSheets = sheets;
    +		} else {
    +			// Fallback: inline