From cff60930fb2814b8cfac02fe6a1428b7c3d585e3 Mon Sep 17 00:00:00 2001 From: udo Date: Sun, 21 Jun 2026 20:29:30 +0000 Subject: [PATCH] Initial docset viewer app --- .gitignore | 6 + README.md | 39 ++ config/settings.php | 67 ++++ data/docsets/.gitkeep | 0 index.php | 40 ++ js/site.js | 65 +++ js/u-query.js | 589 ++++++++++++++++++++++++++++ lib/components.php | 262 +++++++++++++ lib/docset_repository.php | 233 +++++++++++ lib/log.class.php | 41 ++ lib/profiler.class.php | 42 ++ lib/theme_helpers.php | 75 ++++ lib/ulib.php | 271 +++++++++++++ lib/url.class.php | 126 ++++++ tests/smoke.php | 42 ++ themes/common/css/docset-viewer.css | 1 + themes/portal-dark/css/style.css | 313 +++++++++++++++ themes/portal-dark/icon.png | Bin 0 -> 4029 bytes themes/portal-dark/page.html.php | 18 + views/asset.php | 124 ++++++ views/docsets.php | 24 ++ views/index.php | 72 ++++ views/view.php | 34 ++ 23 files changed, 2484 insertions(+) create mode 100755 .gitignore create mode 100755 README.md create mode 100755 config/settings.php create mode 100644 data/docsets/.gitkeep create mode 100755 index.php create mode 100755 js/site.js create mode 100755 js/u-query.js create mode 100755 lib/components.php create mode 100644 lib/docset_repository.php create mode 100755 lib/log.class.php create mode 100755 lib/profiler.class.php create mode 100644 lib/theme_helpers.php create mode 100755 lib/ulib.php create mode 100755 lib/url.class.php create mode 100644 tests/smoke.php create mode 100644 themes/common/css/docset-viewer.css create mode 100644 themes/portal-dark/css/style.css create mode 100644 themes/portal-dark/icon.png create mode 100644 themes/portal-dark/page.html.php create mode 100644 views/asset.php create mode 100644 views/docsets.php create mode 100644 views/index.php create mode 100644 views/view.php diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..d5116aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/log/*.log +/tmp/ +/data/docsets/* +!/data/docsets/.gitkeep +*.tgz +.DS_Store diff --git a/README.md b/README.md new file mode 100755 index 0000000..7860208 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Docset Viewer + +PHP web app for searching and browsing local Zeal/Dash docsets. + +## Local setup + +This project is intended to live under `/root/projects/docset-viewer`, which is served by the local nginx projects web root. + +1. Put docset directories in `data/docsets/`. +2. Open the app through local nginx, for example `/docset-viewer/` on the projects host. +3. Search for symbols/functions/classes, then open entries in the embedded document viewer. + +Docsets are not committed to git. The PHP test docset can be downloaded with: + +```bash +mkdir -p data/docsets tmp +curl -L https://kapeli.com/feeds/PHP.tgz -o tmp/PHP.tgz +tar -xzf tmp/PHP.tgz -C data/docsets +``` + +## Structure + +- `views/index.php` - search form and result list. +- `views/docsets.php` - installed docset list. +- `views/view.php` - app shell around a selected document. +- `views/asset.php` - safe docset asset streaming endpoint. +- `lib/docset_repository.php` - docset discovery, SQLite search, and path containment checks. +- `data/docsets/` - ignored local docset storage. + +## Requirements + +- PHP with `SQLite3`, `SimpleXML`, and `DOM` extensions enabled. +- A Dash/Zeal-compatible `*.docset` with `Contents/Resources/docSet.dsidx`. + +## Validation + +```bash +php tests/smoke.php +``` diff --git a/config/settings.php b/config/settings.php new file mode 100755 index 0000000..e3eb970 --- /dev/null +++ b/config/settings.php @@ -0,0 +1,67 @@ + array( + 'enabled' => $debug_enabled, + 'display_errors' => $debug_enabled, + 'reporting' => $error_reporting_level, + ), + 'url' => array( + 'pretty' => false, + 'root' => '', + 'content-type' => 'html', + ), + 'theme' => array( + 'key' => 'docset-viewer', + 'path' => 'themes/portal-dark/', + 'mode' => 'dark', + 'label' => 'Docset Viewer', + 'footer_text' => 'Docset Viewer - local Zeal/Dash documentation browser.', + 'meta_description' => 'Search and browse local Zeal docsets', + 'theme_color' => '#0f172a', + 'options' => array(), + ), + 'site' => array( + 'name' => 'Docset Viewer', + 'default_page_title' => 'Search Zeal Docsets', + 'include_paths' => ['views/', 'components/'], + 'autostart_session' => false, + 'timezone' => 'UTC', + ), + 'docsets' => array( + 'path' => __DIR__.'/../data/docsets', + 'snippet_length' => 180, + 'search_limit' => 80, + ), + 'track' => array('changes' => array()), + 'filebase' => array('path' => __DIR__.'/../tmp/'), + 'menu' => array( + '' => array('title' => 'Search'), + 'docsets' => array('title' => 'Docsets'), + ), +); + +if(!$GLOBALS['config']['url']['pretty'] && !$GLOBALS['config']['url']['root']) +{ + $sroot = $_SERVER['PHP_SELF'] ? $_SERVER['PHP_SELF'] : ($_SERVER['DOCUMENT_URI'] ?? ''); + if(substr($sroot, -9) == 'index.php') + $GLOBALS['config']['url']['root'] = substr($sroot, 0, -strlen('index.php')); +} + +date_default_timezone_set($GLOBALS['config']['site']['timezone'] ?: 'UTC'); diff --git a/data/docsets/.gitkeep b/data/docsets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/index.php b/index.php new file mode 100755 index 0000000..59d10d7 --- /dev/null +++ b/index.php @@ -0,0 +1,40 @@ +'; + echo '

404 Not Found

'; + echo '

'.safe(URL::$error).'

'; + echo ''; + } + URL::$fragments['main'] = ob_get_clean(); + + Profiler::log('main content: end', -1); + + $page_template = cfg('theme/path').'/page.'.URL::$page_type.'.php'; + if(!file_exists($page_template)) + $page_template = 'themes/common/page.'.URL::$page_type.'.php'; + if(!file_exists($page_template)) + die('fatal error: page template not found ('.$page_template.')'); + require($page_template); + + Log::audit('page:'.URL::$route['page'], URL::$route['l-path']); diff --git a/js/site.js b/js/site.js new file mode 100755 index 0000000..2c311c4 --- /dev/null +++ b/js/site.js @@ -0,0 +1,65 @@ +var enable_debug = true; + +var UI = { + + smoothScrollToNamedAnchors: function() { + $('a[href^="#"]').each(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); + } + }); + }); + }, + + enablePageTransitions: function() { + $(document.body).append(``); + + document.addEventListener('click', e => { + const link = e.target.closest('a[href]'); + if (!link) return; + + e.preventDefault(); + + document.startViewTransition(() => { + window.location.href = link.href; + }); + }); + }, + + init: function() { + //UI.enablePageTransitions(); + UI.smoothScrollToNamedAnchors(); + document.body.classList.add('loaded'); + }, + +} + +$.ready(UI.init); diff --git a/js/u-query.js b/js/u-query.js new file mode 100755 index 0000000..907e8ba --- /dev/null +++ b/js/u-query.js @@ -0,0 +1,589 @@ +(function (root, factory) { + // this is the stupid UMD pattern, but eh we lost that battle a long time ago + if (typeof exports === 'object' && typeof module !== 'undefined') { + var exports_obj = factory(); + module.exports = exports_obj.$; + // Also export individual components + for (var key in exports_obj) { + if (key !== '$' && exports_obj.hasOwnProperty(key)) { + exports[key] = exports_obj[key]; + } + } + } else if (typeof define === 'function' && define.amd) { + define([], factory); + } else { + var exports_obj = factory(); + root.$ = exports_obj.$; + for (var key in exports_obj) { + if (key !== '$' && exports_obj.hasOwnProperty(key)) { + root[key] = exports_obj[key]; + } + } + } +}(typeof self !== 'undefined' ? self : this, function () { + +// a collection of DOM utility functions that I thought were cool in jQuery + +class EventEmitter { + constructor() { + /** Map> */ + this._topics = new Map(); + } + + /** + * Subscribe to `topic`. If `slot_key` is provided (truthy), + * it dedupes by that key; otherwise a unique Symbol is used. + * Returns an unsubscribe fn. + */ + on(topic, handler, slot_key = null) { + let map = this._topics.get(topic); + if (!map) { + map = new Map(); + this._topics.set(topic, map); + } + // use the provided slot_key or a fresh Symbol() + const key = slot_key != null ? slot_key : Symbol(); + map.set(key, handler); + return () => this.off(topic, key); + } + + /** + * Unsubscribe by handler function or by slot_key. + */ + off(topic, handlerOrSlotKey) { + const map = this._topics.get(topic); + if (!map) return; + + // if it matches a slotKey directly, remove it + if (map.has(handlerOrSlotKey)) { + map.delete(handlerOrSlotKey); + } else { + // otherwise assume it's a function: remove all matching fns + for (const [key, fn] of map.entries()) { + if (fn === handlerOrSlotKey) { + map.delete(key); + } + } + } + + if (map.size === 0) { + this._topics.delete(topic); + } + } + + /** + * Emit to all handlers on `topic`. Handlers returning + * 'remove_handler' are auto-removed. + * Returns the number of handlers invoked. + */ + emit(topic, ...args) { + let count = 0; + const map = this._topics.get(topic); + if (!map) return count; + + for (const [key, fn] of Array.from(map.entries())) { + const res = fn(...args); + count++; + if (res === 'remove_handler') { + map.delete(key); + } + } + + if (map.size === 0) { + this._topics.delete(topic); + } + return count; + } +} + +class QueryWrapper extends Array { + constructor(elements) { + super(); + if (elements) { + this.push(...(Array.isArray(elements) ? elements : [elements])); + } + } +} + +$ = function(selector_or_element) { + let elements; + if (selector_or_element instanceof Element || selector_or_element === document || selector_or_element === window) { + elements = [selector_or_element]; + } else if (typeof selector_or_element === 'string') { + // Check if string looks like HTML (starts with < and ends with >) + if (selector_or_element.trim().charAt(0) === '<' && selector_or_element.trim().charAt(selector_or_element.trim().length - 1) === '>') { + // Create element from HTML string + let temp = document.createElement('div'); + temp.innerHTML = selector_or_element.trim(); + elements = Array.from(temp.children); + } else { + // Treat as CSS selector + elements = Array.from(document.querySelectorAll(selector_or_element)); + } + } else if (selector_or_element instanceof NodeList || Array.isArray(selector_or_element)) { + elements = Array.from(selector_or_element); + } else { + elements = [selector_or_element]; + } + return new QueryWrapper(elements); +}; + +$.options = { + alwaysDoDifferentialUpdate: true, +}; + +$.events = new EventEmitter(); +$.on = $.events.on.bind($.events); +$.off = $.events.off.bind($.events); +$.emit = $.events.emit.bind($.events); + +$.each = function(selector, callback) { + let elements; + if (typeof selector === 'string') { + elements = document.querySelectorAll(selector); + } else if (selector instanceof QueryWrapper) { + elements = selector; + } else { + elements = selector; + } + for (let i = 0; i < elements.length; i++) { + callback(elements[i], i); + } +} + +$.post = function(url, data, callback) { + return $.ajax({ + method: 'POST', + url: url, + data: data, + success: callback, + error: function(xhr) { + console.error('POST request failed:', xhr); + } + }); +} + +$.get = function(url, callback) { + return $.ajax({ + method: 'GET', + url: url, + success: callback, + error: function(xhr) { + console.error('GET request failed:', xhr); + } + }); +} + +$.ajax = function(options) { + return new Promise((resolve, reject) => { + var xhr = new XMLHttpRequest(); + xhr.open(options.method || 'GET', options.url, true); + + // Set default headers + if (options.method === 'POST' || options.data) { + xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); + } + + // Set custom headers + if (options.headers) { + for (var key in options.headers) { + xhr.setRequestHeader(key, options.headers[key]); + } + } + + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + var response; + try { + response = JSON.parse(xhr.responseText); + } catch (e) { + // If JSON parsing fails, use raw response + response = xhr.responseText; + } + + if (xhr.status >= 200 && xhr.status < 300) { + if (options.success) options.success(response); + resolve(response); + } else { + if (options.error) options.error(xhr); + reject(xhr); + } + } + }; + + var data = null; + if (options.data) { + data = typeof options.data === 'string' ? options.data : JSON.stringify(options.data); + } + + xhr.send(data); + }); +} + +$.ready = function(callback) { + if (document.readyState !== 'loading') { + callback(); + } else { + document.addEventListener('DOMContentLoaded', callback); + } +} + +// Helper function to execute scripts in HTML content +function executeScripts(htmlContent) { + // Create a temporary container to parse the HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlContent; + + // Find all script tags and disable them temporarily + const scripts = tempDiv.querySelectorAll('script'); + const scriptData = []; + + scripts.forEach((script) => { + // Store script data for later execution + scriptData.push({ + content: script.textContent || script.innerText || '', + src: script.src, + type: script.type, + nonce: script.nonce, + async: script.async, + defer: script.defer + }); + + // Disable the script by changing its type + script.type = 'text/disabled-script'; + }); + + // Return the modified HTML and script data + return { + html: tempDiv.innerHTML, + scripts: scriptData + }; +} + +// Helper function to execute a single script +function executeScript(scriptInfo) { + if (scriptInfo.src) { + // External script + const newScript = document.createElement('script'); + if (scriptInfo.type && scriptInfo.type !== 'text/disabled-script') { + newScript.type = scriptInfo.type; + } + if (scriptInfo.nonce) newScript.nonce = scriptInfo.nonce; + if (scriptInfo.async) newScript.async = scriptInfo.async; + if (scriptInfo.defer) newScript.defer = scriptInfo.defer; + newScript.src = scriptInfo.src; + + document.head.appendChild(newScript); + } else if (scriptInfo.content.trim()) { + // Inline script + const newScript = document.createElement('script'); + if (scriptInfo.type && scriptInfo.type !== 'text/disabled-script') { + newScript.type = scriptInfo.type; + } + if (scriptInfo.nonce) newScript.nonce = scriptInfo.nonce; + newScript.text = scriptInfo.content; + + // Execute by inserting and immediately removing + document.head.appendChild(newScript); + document.head.removeChild(newScript); + } +} + +// Add all the jQuery-like methods to QueryWrapper +QueryWrapper.prototype.parent = function() { + const parents = Array.from(this).map(el => el.parentNode).filter(el => el); + return new QueryWrapper(parents); +} + +QueryWrapper.prototype.load = function(url, opt = {}) { + return new Promise((resolve, reject) => { + const ajaxOptions = { + method: opt.method || (opt.postData ? 'POST' : 'GET'), + url: url, + success: (response) => { + if(opt.replace) + this.replaceWith(response, opt); + else + this.html(response, opt); + if (opt.onLoad) opt.onLoad(response, null); + resolve(this); + }, + error: (xhr) => { + if (opt.onError) opt.onError(xhr); + reject(new Error('Failed to load: ' + url)); + } + }; + + if (opt.postData) { + ajaxOptions.data = opt.postData; + } + + $.ajax(ajaxOptions); + }); +} + +QueryWrapper.prototype.html = function(opt_content = false, prop = {}) { + if (opt_content === false) { + return Array.from(this).map(el => el.innerHTML).join(''); + } + if(typeof prop.diff == 'undefined') + prop.diff = $.options.alwaysDoDifferentialUpdate; + if(prop.diff === true) { + const temp = document.createElement('div'); + temp.innerHTML = opt_content; + this.forEach(el => { + morphdom(el, temp, { childrenOnly: true }); + }); + } else { + this.forEach(el => { + const result = executeScripts(opt_content); + el.innerHTML = result.html; + result.scripts.forEach(executeScript); + }); + } + return this; +} + +QueryWrapper.prototype.text = function(opt_content = false) { + if (opt_content === false) { + return Array.from(this).map(el => el.textContent).join(''); + } + this.forEach(el => { + el.textContent = opt_content; + }); + return this; +} + +QueryWrapper.prototype.replaceWith = function(content, prop = {}) { + if(typeof prop.diff == 'undefined') + prop.diff = $.options.alwaysDoDifferentialUpdate; + if(prop.diff === true) { + this.forEach(el => { + morphdom(el, content); + }); + } else { + if(typeof content === 'string') { + this.forEach(el => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = content; + el.parentNode.replaceChild(tempDiv.firstChild, el); + }); + } else if(content instanceof Element) { + this.forEach(el => { + el.parentNode.replaceChild(content.cloneNode(true), el); + }); + } + } + return this; +} + +QueryWrapper.prototype.query = function(selector) { + const found = Array.from(this).reduce((acc, el) => { + const results = el.querySelectorAll(selector); + return acc.concat(Array.from(results)); + }, []); + return new QueryWrapper(found); +} + +QueryWrapper.prototype.find = function(selector) { + const found = Array.from(this).reduce((acc, el) => { + const results = el.querySelectorAll(selector); + return acc.concat(Array.from(results)); + }, []); + return new QueryWrapper(found); +} + +QueryWrapper.prototype.on = function(event, handler) { + this.forEach(function(el) { + el.addEventListener(event, handler); + }); + return this; +} + +QueryWrapper.prototype.off = function(event, handler) { + this.forEach(function(el) { + el.removeEventListener(event, handler); + }); + return this; +} + +QueryWrapper.prototype.hide = function() { + return this.css({ display: 'none' }); +} + +QueryWrapper.prototype.show = function() { + return this.css({ display: '' }); +} + +QueryWrapper.prototype.toggle = function() { + this.forEach(function(el) { + el.style.display = (el.style.display === 'none' || el.style.display === '') ? '' : 'none'; + }); + return this; +} + +QueryWrapper.prototype.attr = function(name, value) { + if (value === undefined) { + return this.length > 0 ? this[0].getAttribute(name) : null; + } + this.forEach(function(el) { + el.setAttribute(name, value); + }); + return this; +} + +QueryWrapper.prototype.addClass = function(classNameOrList) { + var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList]; + this.forEach(function(el) { + classList.forEach(function(classNames) { + // Split space-separated class names + var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; }); + classes.forEach(function(className) { + el.classList.add(className); + }); + }); + }); + return this; +} + +QueryWrapper.prototype.removeClass = function(classNameOrList) { + var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList]; + this.forEach(function(el) { + classList.forEach(function(classNames) { + // Split space-separated class names + var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; }); + classes.forEach(function(className) { + el.classList.remove(className); + }); + }); + }); + return this; +} + +QueryWrapper.prototype.toggleClass = function(classNameOrList, force) { + var classList = Array.isArray(classNameOrList) ? classNameOrList : [classNameOrList]; + this.forEach(function(el) { + classList.forEach(function(classNames) { + // Split space-separated class names + var classes = classNames.toString().split(/\s+/).filter(function(name) { return name.length > 0; }); + classes.forEach(function(className) { + if (typeof force !== 'undefined') { + el.classList.toggle(className, force); + } else { + el.classList.toggle(className); + } + }); + }); + }); + return this; +} + +QueryWrapper.prototype.empty = function() { + this.forEach(function(el) { + el.innerHTML = ''; + }); + return this; +} + +QueryWrapper.prototype.css = function(styles, optOrValue) { + // If no arguments or first argument is a string (getter mode) + if (arguments.length === 0 || (typeof styles === 'string' && arguments.length === 1)) { + if (this.length === 0) return undefined; + var el = this[0]; + if (typeof styles === 'string') { + // Get computed style for a specific property + return window.getComputedStyle(el)[styles]; + } else { + // Return computed styles object (not commonly used) + return window.getComputedStyle(el); + } + } + + // Setter mode + this.forEach(function(el) { + if (typeof styles === 'string' && arguments.length >= 2) { + // Single property setter: .css('prop', 'value') + el.style[styles] = optOrValue; + } else if (typeof styles === 'object') { + // Multiple properties setter: .css({prop1: 'value1', prop2: 'value2'}) + for (var key in styles) { + el.style[key] = styles[key]; + } + } + }); + return this; +} + +QueryWrapper.prototype.each = function(callback) { + this.forEach(function(el, index) { + callback(el, index); + }); + return this; +} + +QueryWrapper.prototype.append = function(child_or_html) { + this.forEach(function(el) { + if (typeof child_or_html === 'string' && el.insertAdjacentHTML) { + if (child_or_html.includes(' max) return max; + return v; +} + +function pick_entry_from_range(array, value) { + if (!array) return {}; + let result = {}; + for (const [pv] of Object.entries(array)) { + if (value >= pv.from && value <= pv.to) result = pv; + } + return result; +} + +return { + $: $, + EventEmitter: EventEmitter, + QueryWrapper: QueryWrapper, + first: first, + clamp: clamp, + pick_entry_from_range: pick_entry_from_range +}; + +})); \ No newline at end of file diff --git a/lib/components.php b/lib/components.php new file mode 100755 index 0000000..9f949c6 --- /dev/null +++ b/lib/components.php @@ -0,0 +1,262 @@ + function($prop, &$context) { + $context['current_component_id'] = $prop['id']; + return('
'); + }, + + 'end' => function($prop) { + ?>'); + }, + + 'render' => function($prop, &$context) { + return($context['begin']($prop, $context) . $context['end']($prop)); + }, + + 'about' => 'This is an example component that wraps content in a div and logs when it is finalized.', + + ]; */ + + $GLOBALS['render_funcs'] = array(); + $GLOBALS['id_counter'] = $GLOBALS['id_counter'] ?? 1; + + function component_error_banner($s) + { + return ''; + } + + function component_expand_search_paths($search_path = false) + { + $paths = array(); + if(is_array($search_path)) + $paths = $search_path; + else if(is_string($search_path) && trim($search_path) !== '') + $paths = array($search_path); + + $normalized = array(); + foreach($paths as $path) + { + $base = rtrim((string)$path, '/'); + if($base === '') + continue; + $normalized[] = $base; + if(substr($base, -11) !== '/components') + $normalized[] = $base.'/components'; + } + return array_values(array_unique($normalized)); + } + + function component_caller_dir() + { + $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 8); + foreach($trace as $frame) + { + if(!isset($frame['file']) || !is_string($frame['file'])) + continue; + if(realpath($frame['file']) === realpath(__FILE__)) + continue; + return dirname($frame['file']); + } + return getcwd(); + } + + function component_normalize_name($file_name) + { + return preg_replace('/\.php$/', '', trim((string)$file_name)); + } + + function component_resolve_file($file_name, $search_path = false) + { + $component_name = component_normalize_name($file_name); + $candidates = array($component_name.'.php'); + if(!str_starts_with($component_name, 'components/')) + $candidates[] = 'components/'.$component_name.'.php'; + if(!str_starts_with($component_name, 'components/')) + { + foreach(component_expand_search_paths($search_path) as $path) + $candidates[] = $path.'/'.$component_name.'.php'; + } + if(!str_starts_with($component_name, 'interface/')) + $candidates[] = 'interface/'.$component_name.'.php'; + if(!str_starts_with($component_name, 'packages/')) + $candidates[] = 'packages/'.$component_name.'.php'; + foreach(array_unique($candidates) as $candidate) + { + if(file_exists($candidate)) + return $candidate; + } + return false; + } + + /** + * Loads a component from file system and registers it in the global registry + * + * Component files are searched in this order: + * 1. exact file path passed to the loader + * 2. components/{component_name}.php + * 3. caller-relative path and caller/components/{component_name}.php + * 4. interface/{component_name}.php and packages/{component_name}.php + * + * Component files should return an array with render functions like: + * return ['render' => function($prop) { ... }]; + * ERP-style files that return a single callable are treated as the default render function. + */ + function component_get_func($file_name, $return_false_if_not_found = false, $search_path = false) + { + $component_name = component_normalize_name($file_name); + $result = []; + // Check if component is already loaded in global registry + if(!isset($GLOBALS['render_funcs'][$component_name])) + { + $component_file = component_resolve_file($component_name, $search_path); + if($component_file) + { + $result['component_file'] = $component_file; + } + else + { + // Component file not found - create error component + if($return_false_if_not_found) return(false); + $result['component_file'] = false; + $result['error'] = 'Component not found: '.$component_name; + $result['render'] = function() use($component_name) { + return component_error_banner('component not found: '.$component_name); + }; + } + if($result['component_file']) + { + $component_definition = require($result['component_file']); + if(is_callable($component_definition)) + $result['render'] = $component_definition; + else if(is_array($component_definition)) + { + foreach($component_definition as $k => $v) + $result[$k] = $v; + } + else + $result['render'] = ''; + } + // Register component in global registry for future use + $GLOBALS['render_funcs'][$component_name] = $result; + } + return($GLOBALS['render_funcs'][$component_name]); + } + +/** + * Checks if a component exists without loading it + * Returns true if component file can be found, false otherwise + */ +function component_exists($file_name) +{ + $caller_dir = component_caller_dir(); + return(component_get_func($file_name, true, $caller_dir) !== false); +} + +/** + * Loads a component and returns its definition + * Same as component_get_func but with caller directory detection + */ +function component_load($file_name) +{ + $caller_dir = component_caller_dir(); + return(component_get_func($file_name, true, $caller_dir)); +} + +function component_call($file_name, $render_call, $prop = array()) +{ + $prop['render_call'] = $render_call; + return component($file_name, $prop); +} + +/** + * Declares an inline component directly in code (not from file) + * + * @param string $file_name - Component name for registry + * @param array $render_prop - Component definition with render functions + * + * Example: + * component_declare('my-button', [ + * 'render' => function($prop) { return ""; } + * ]); + */ +function component_declare($file_name, $render_prop) +{ + // Register the inline component directly in global registry + $GLOBALS['render_funcs'][$file_name] = $render_prop; +} + +/** + * Main component rendering function - the heart of the component system + * + * @param string $file_name - Component name or path + * @param array $prop - Properties/data to pass to component + * @return mixed - Component output from the selected render method + * + * Component calling examples: + * component('components/example/theme-switcher') + * component_call('components/workspace/panel', 'render', ['title' => 'Status']) + * component('workspace/panel', ['render_call' => 'render']) + */ +function component($file_name, $prop = array()) +{ + Profiler::log('component '.$file_name, 1); + $caller_dir = component_caller_dir(); + if($file_name == '') + return(component_error_banner('[component name empty]')); + $file_name = component_normalize_name($file_name); + + // Default render method is 'render', but can be overridden + $prop['render_call'] = first($prop['render_call'] ?? false, 'render'); + + // Support for calling specific render methods: 'component:method' + if(stristr($file_name, ':')) // you can specify which render function to call + { + $prop['render_call'] = $file_name; + $file_name = nibble(':', $prop['render_call']); + } + + // Get component definition from registry (load if not already loaded) + $renderer = $GLOBALS['render_funcs'][$file_name] ?? false; + if(!$renderer) + { + // Component not in registry - try to load from file + component_get_func($file_name, false, $caller_dir); + $renderer = $GLOBALS['render_funcs'][$file_name] ?? false; + } + + // Generate unique ID for component instance if not provided + $prop['id'] = !empty($prop['id']) ? $prop['id'] : 'c'.($GLOBALS['id_counter']++); + $prop['filename'] = $file_name; + + if(isset($prop[':before']) && $prop[':before'] !== '') + echo $prop[':before']; + + ob_start(); + if(isset($renderer[$prop['render_call']]) && is_callable($renderer[$prop['render_call']])) + $result = ($renderer[$prop['render_call']]($prop, $renderer)); + else if(isset($renderer[$prop['render_call']])) + $result = ($renderer[$prop['render_call']]); + else + $result = component_error_banner('component render method not found: '.$file_name.':'.$prop['render_call']); + $printed_result = ob_get_clean(); + + if($result === null) + $result = $printed_result; + else if(is_string($result)) + $result .= $printed_result; + else + echo $printed_result; + + if(isset($prop[':after']) && $prop[':after'] !== '') + echo $prop[':after']; + + Profiler::log(false, -1); + return($result); +} + diff --git a/lib/docset_repository.php b/lib/docset_repository.php new file mode 100644 index 0000000..7593ac6 --- /dev/null +++ b/lib/docset_repository.php @@ -0,0 +1,233 @@ +root = realpath((string)($root ?: cfg('docsets/path'))) ?: (string)($root ?: cfg('docsets/path')); + } + + public function listDocsets(): array + { + if(!is_dir($this->root)) + return array(); + $docsets = array(); + foreach(scandir($this->root) ?: array() as $entry) + { + if($entry === '.' || $entry === '..') + continue; + if(!str_ends_with($entry, '.docset')) + continue; + try + { + $docsets[] = $this->readDocset($entry); + } + catch(Throwable $e) + { + Log::text('docsets', 'Skipping '.$entry.': '.$e->getMessage()); + } + } + usort($docsets, fn($a, $b) => strcasecmp($a['name'], $b['name'])); + return $docsets; + } + + public function getDocset(string $id): ?array + { + $id = $this->cleanId($id); + if($id === '') + return null; + $path = $this->root.'/'.$id.'.docset'; + if(!is_dir($path)) + return null; + return $this->readDocset($id.'.docset'); + } + + public function search(string $query, string $docsetId = '', int $limit = 80): array + { + $query = trim($query); + if($query === '') + return array(); + $docsets = $docsetId !== '' ? array_filter(array($this->getDocset($docsetId))) : $this->listDocsets(); + $all = array(); + foreach($docsets as $docset) + { + $all = array_merge($all, $this->searchOne($docset, $query, $limit)); + } + usort($all, function($a, $b) use ($query) { + $qa = strtolower($query); + $an = strtolower($a['name']); + $bn = strtolower($b['name']); + $scoreA = $an === $qa ? 0 : (str_starts_with($an, $qa) ? 1 : 2); + $scoreB = $bn === $qa ? 0 : (str_starts_with($bn, $qa) ? 1 : 2); + return $scoreA <=> $scoreB ?: strcasecmp($a['name'], $b['name']); + }); + return array_slice($all, 0, $limit); + } + + private function searchOne(array $docset, string $query, int $limit): array + { + if(!class_exists('SQLite3')) + throw new RuntimeException('PHP SQLite3 extension is not installed.'); + $db = new SQLite3($docset['index_path'], SQLITE3_OPEN_READONLY); + try + { + $sql = 'select name, type, path from searchIndex where name like :prefix escape "\\" order by length(name), lower(name) limit :limit'; + $stmt = $db->prepare($sql); + $stmt->bindValue(':prefix', $this->escapeLike($query).'%', SQLITE3_TEXT); + $stmt->bindValue(':limit', max(1, min($limit, 200)), SQLITE3_INTEGER); + $rows = $this->fetchRows($stmt); + if(count($rows) < $limit) + { + $sql = 'select name, type, path from searchIndex where name like :contains escape "\\" and name not like :prefix escape "\\" order by lower(name) limit :limit'; + $stmt = $db->prepare($sql); + $stmt->bindValue(':contains', '%'.$this->escapeLike($query).'%', SQLITE3_TEXT); + $stmt->bindValue(':prefix', $this->escapeLike($query).'%', SQLITE3_TEXT); + $stmt->bindValue(':limit', max(1, min($limit - count($rows), 200)), SQLITE3_INTEGER); + $rows = array_merge($rows, $this->fetchRows($stmt)); + } + return array_map(function($row) use ($docset) { + $row['docset'] = $docset['id']; + $row['docset_name'] = $docset['name']; + $row['href'] = URL::Link('view', array('docset' => $docset['id'], 'path' => $row['path'])); + return $row; + }, $rows); + } + finally + { + $db->close(); + } + } + + private function fetchRows(SQLite3Stmt $stmt): array + { + $result = $stmt->execute(); + $rows = array(); + if($result) + { + while($row = $result->fetchArray(SQLITE3_ASSOC)) + $rows[] = $row; + $result->finalize(); + } + $stmt->close(); + return $rows; + } + + public function resolveDocumentPath(string $docsetId, string $relativePath): ?array + { + $docset = $this->getDocset($docsetId); + if(!$docset) + return null; + $relativePath = ltrim(str_replace('\\', '/', $relativePath), '/'); + $anchorParts = explode('#', $relativePath, 2); + $queryParts = explode('?', $anchorParts[0], 2); + $filePath = $queryParts[0]; + $anchor = $anchorParts[1] ?? ''; + $real = realpath($docset['documents_path'].'/'.$filePath); + $documentsRoot = realpath($docset['documents_path']); + if(!$real || !$documentsRoot || !$this->isWithin($real, $documentsRoot) || !is_file($real)) + return null; + return array('docset' => $docset, 'file' => $real, 'relative' => $filePath, 'anchor' => $anchor); + } + + public function assetUrl(string $docsetId, string $relativePath): string + { + return URL::Link('asset', array('docset' => $docsetId, 'path' => ltrim($relativePath, '/'))); + } + + private function readDocset(string $entry): array + { + $id = substr($entry, 0, -strlen('.docset')); + $base = $this->root.'/'.$entry; + $resources = $base.'/Contents/Resources'; + $index = $resources.'/docSet.dsidx'; + $documents = $resources.'/Documents'; + if(!is_file($index)) + throw new RuntimeException('missing docSet.dsidx'); + if(!is_dir($documents)) + throw new RuntimeException('missing Documents directory'); + $plist = $this->readInfoPlist($base.'/Contents/Info.plist'); + $count = $this->countEntries($index); + return array( + 'id' => $id, + 'name' => $plist['CFBundleName'] ?? $id, + 'family' => $plist['DocSetPlatformFamily'] ?? '', + 'index_file' => $plist['dashIndexFilePath'] ?? 'index.html', + 'path' => $base, + 'index_path' => $index, + 'documents_path' => $documents, + 'entry_count' => $count, + ); + } + + private function countEntries(string $index): int + { + if(!class_exists('SQLite3')) + return 0; + $db = new SQLite3($index, SQLITE3_OPEN_READONLY); + try + { + $result = $db->querySingle('select count(*) from searchIndex'); + return (int)$result; + } + catch(Throwable $e) + { + return 0; + } + finally + { + $db->close(); + } + } + + private function readInfoPlist(string $path): array + { + if(!is_file($path)) + return array(); + $xml = @simplexml_load_file($path); + if(!$xml || !isset($xml->dict)) + return array(); + $dict = array(); + $key = null; + foreach($xml->dict->children() as $node) + { + if($node->getName() === 'key') + { + $key = (string)$node; + continue; + } + if($key !== null) + { + $dict[$key] = (string)$node; + $key = null; + } + } + return $dict; + } + + private function cleanId(string $id): string + { + $id = trim($id); + $out = ''; + foreach(str_split($id) as $char) + { + if(ctype_alnum($char) || $char === '-' || $char === '_' || $char === '.') + $out .= $char; + } + return trim($out, '.'); + } + + private function escapeLike(string $value): string + { + $value = str_replace('\\', '\\\\', $value); + $value = str_replace('%', '\\%', $value); + return str_replace('_', '\\_', $value); + } + + private function isWithin(string $path, string $root): bool + { + $root = rtrim($root, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + return str_starts_with($path, $root) || $path === rtrim($root, DIRECTORY_SEPARATOR); + } +} diff --git a/lib/log.class.php b/lib/log.class.php new file mode 100755 index 0000000..c1f1814 --- /dev/null +++ b/lib/log.class.php @@ -0,0 +1,41 @@ + 0) // if greater than zero, update indent after logging + self::$indent_str = str_repeat(' ', self::$indent_level * 2); + self::$last = $thistime; + self::$current = $absoluteMS; + return($thistime); + } + + static function get_time() + { + return(1000*(microtime(true) - self::$start)); + } + + static function start() + { + self::$start = self::$last = microtime(true); + } + +} diff --git a/lib/theme_helpers.php b/lib/theme_helpers.php new file mode 100644 index 0000000..2f6c560 --- /dev/null +++ b/lib/theme_helpers.php @@ -0,0 +1,75 @@ + + + <?= first(URL::$route['page-title'] ?? false, cfg('site/default_page_title')).' | '.cfg('site/name') ?> + + + + + + > + +

'.safe($label).''; +} + +function clamp($v, $min, $max) +{ + if($v < $min) return $min; + if($v > $max) return $max; + return $v; +} + +function pick_entry_from_range($array, $value) +{ + if(!is_array($array)) return []; + $result = []; + foreach($array as $pv) + if($value >= $pv['from'] && $value <= $pv['to']) $result = $pv; + return $result; +} + +/** + * Can have any number of arguments. Returns the first of its arguments that is not false, empty string, or null. + */ +function first() +{ + $args = func_get_args(); + foreach($args as $v) + { + if(isset($v) && $v !== false && $v !== '' && $v !== null) + return($v); + } + return(''); +} + +function alnum($s, $replace_with = '_', $trim = true) +{ + if($trim) $s = trim(strtolower($s)); + return(preg_replace("/[^[:alnum:][:space:]]/u", $replace_with, $s)); +} + +/** + * Append a string to the given file. + */ +function write_to_file($filename, $content) +{ + if (is_array($content)) $content = json_encode($content); + $open = fopen($filename, 'a+'); + fwrite($open, $content); + fclose($open); + @chmod($filename, 0777); +} + +# **************************** ARRAY FUNCTIONS ****************************** + +/** + * Returns a value from the $GLOBALS['config'] array identified by $key. + * Sub-array values can be addressed by using the '/' character as a separator. + */ +function cfg($key) +{ + $config = $GLOBALS['config']; + $seg = explode('/', $key); + $lastSeg = array_pop($seg); + foreach($seg as $s) + { + if(is_array($config) && array_key_exists($s, $config) && is_array($config[$s])) + $config = $config[$s]; + else + return null; + } + if(!is_array($config) || !array_key_exists($lastSeg, $config)) + return null; + return($config[$lastSeg]); +} + + +# **************************** STRING/FORMATTING FUNCTIONS ****************************** + +/** + * Convert any base number into another number of another base system. + */ +function base_convert_any($numberInput, $fromBaseInput, $toBaseInput) +{ + if ($fromBaseInput==$toBaseInput) return $numberInput; + $fromBase = str_split($fromBaseInput,1); + $toBase = str_split($toBaseInput,1); + $number = str_split($numberInput,1); + $fromLen=strlen($fromBaseInput); + $toLen=strlen($toBaseInput); + $numberLen=strlen($numberInput); + $retval=''; + if ($toBaseInput == '0123456789') + { + $retval=0; + for ($i = 1;$i <= $numberLen; $i++) + $retval = bcadd($retval, bcmul(array_search($number[$i-1], $fromBase),bcpow($fromLen,$numberLen-$i))); + return $retval; + } + if ($fromBaseInput != '0123456789') + $base10=base_convert_any($numberInput, $fromBaseInput, '0123456789'); + else + $base10 = $numberInput; + if ($base10 $content_file.'.php'); + $dir_index_file = $content_file.'/index.php'; + if(file_exists($dir_index_file)) + return array('file' => $dir_index_file); + $lpath_parts = array_values(array_filter(explode('/', $lpath), function($segment) { + return $segment !== ''; + })); + if(sizeof($lpath_parts) > 1) + { + $last_seg = array_pop($lpath_parts); + $parent_file = $base_dir.'/'.implode('/', $lpath_parts).'/index.php'; + if(file_exists($parent_file)) + return array('file' => $parent_file, 'param' => $last_seg); + } + return false; + } + + static function Link($path, $params = false) + { + $path = ltrim((string)$path, '?'); + $query = $params !== false ? http_build_query($params) : ''; + if(cfg('url/pretty')) + { + return($GLOBALS['config']['url']['root'].$path.($query !== '' ? '?'.$query : '')); + } + else + { + if($path === '') + return($GLOBALS['config']['url']['root'].($query !== '' ? '?'.$query : '')); + return($GLOBALS['config']['url']['root'].'?'.$path.($query !== '' ? '&'.$query : '')); + } + } + + # redirect to URL and quit + static function Redirect($url = '', $params = array()) + { + header('location: '.self::Link($url, $params)); + die(); + } + + +} diff --git a/tests/smoke.php b/tests/smoke.php new file mode 100644 index 0000000..d45ed90 --- /dev/null +++ b/tests/smoke.php @@ -0,0 +1,42 @@ +listDocsets(); +assert_true(is_array($docsets), 'docset list returns an array'); + +$phpDocset = $repo->getDocset('PHP'); +if($phpDocset) +{ + assert_true($phpDocset['entry_count'] > 1000, 'PHP docset has indexed entries'); + $results = $repo->search('array_map', 'PHP', 5); + assert_true(count($results) > 0, 'PHP docset search returns results'); + assert_true(isset($results[0]['href']), 'search result contains view URL'); +} + +echo "Smoke tests passed\n"; diff --git a/themes/common/css/docset-viewer.css b/themes/common/css/docset-viewer.css new file mode 100644 index 0000000..106ae39 --- /dev/null +++ b/themes/common/css/docset-viewer.css @@ -0,0 +1 @@ +.docset-hero{display:flex;justify-content:space-between;gap:2rem;align-items:center}.eyebrow{text-transform:uppercase;letter-spacing:.12em;color:#7dd3fc;font-size:.78rem;font-weight:700}.docset-count{min-width:8rem;text-align:center;font-size:3rem;font-weight:800;color:#e0f2fe}.docset-count span{display:block;font-size:.85rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.08em}.search-form{display:grid;grid-template-columns:minmax(12rem,1fr) minmax(10rem,16rem) auto;gap:.75rem;align-items:end}.search-form label{position:absolute;left:-10000px}.search-form input,.search-form select,.mini-search input{width:100%;border:1px solid rgba(148,163,184,.35);border-radius:.75rem;background:rgba(15,23,42,.78);color:#e2e8f0;padding:.8rem .95rem}.search-form button,.mini-search button{border:0;border-radius:.75rem;background:#38bdf8;color:#082f49;font-weight:800;padding:.82rem 1.1rem;cursor:pointer}.notice,.warning{border-color:rgba(250,204,21,.35);background:rgba(113,63,18,.22)}.result-list{display:grid;gap:.55rem}.result-row{display:grid;gap:.2rem;padding:.85rem 1rem;border:1px solid rgba(148,163,184,.2);border-radius:.85rem;background:rgba(15,23,42,.45);text-decoration:none}.result-row:hover{border-color:#38bdf8;background:rgba(14,116,144,.2)}.result-title{color:#f8fafc;font-size:1.05rem;font-weight:750}.result-meta{color:#7dd3fc;font-size:.86rem}.result-path{color:#94a3b8;font-size:.78rem;overflow-wrap:anywhere}.docset-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:1rem}.docset-card{border:1px solid rgba(148,163,184,.22);border-radius:1rem;padding:1rem;background:rgba(15,23,42,.45)}.docset-card h2{margin-top:0}.muted{color:#94a3b8}.doc-viewer-shell{padding:0;overflow:hidden}.viewer-toolbar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:1rem;border-bottom:1px solid rgba(148,163,184,.2)}.viewer-toolbar h1{margin:0}.viewer-toolbar p{margin:.2rem 0 0;color:#94a3b8;overflow-wrap:anywhere}.mini-search{display:flex;gap:.5rem;min-width:20rem}.doc-frame{display:block;width:100%;height:72vh;border:0;background:white}@media(max-width:760px){.docset-hero,.viewer-toolbar{display:block}.search-form{grid-template-columns:1fr}.mini-search{min-width:0;margin-top:1rem}.doc-frame{height:68vh}} \ No newline at end of file diff --git a/themes/portal-dark/css/style.css b/themes/portal-dark/css/style.css new file mode 100644 index 0000000..90001c3 --- /dev/null +++ b/themes/portal-dark/css/style.css @@ -0,0 +1,313 @@ +:root { + --bg-color: #0f172a; + --bg-secondary: #1e293b; + --surface: #1e293b; + --surface-elevated: #334155; + --surface-hover: #475569; + --primary: #60a5fa; + --primary-dark: #3b82f6; + --primary-light: #93c5fd; + --secondary: #a78bfa; + --accent: #22d3ee; + --text-primary: #f1f5f9; + --text-secondary: #cbd5e1; + --text-muted: #94a3b8; + --border: #334155; + --border-hover: #475569; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4); + --radius: 8px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-sm: 4px; + --glass-bg: rgba(30, 41, 59, 0.8); + --success: #10b981; + --success-bg: rgba(16, 185, 129, 0.1); + --success-border: rgba(16, 185, 129, 0.3); + --success-text: #10b981; + --warning: #f59e0b; + --warning-bg: rgba(245, 158, 11, 0.1); + --warning-border: rgba(245, 158, 11, 0.3); + --warning-text: #f59e0b; + --error: #ef4444; + --error-bg: rgba(239, 68, 68, 0.1); + --error-border: rgba(239, 68, 68, 0.3); + --error-text: #ef4444; + --info: #3b82f6; + --info-bg: rgba(59, 130, 246, 0.1); + --info-border: rgba(59, 130, 246, 0.3); + --info-text: #3b82f6; + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'SFMono-Regular', Consolas, monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } +html { font-size: 16px; scroll-behavior: smooth; } +body { + font-family: var(--font-sans); + font-size: 1rem; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-color); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +a { color: var(--primary); text-decoration: none; transition: all 0.2s ease; } +a:hover { color: var(--primary-light); text-decoration: underline; } + +::selection { + background: rgba(96, 165, 250, 0.3); + color: var(--text-primary); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse 80% 50% at 20% -10%, rgba(96, 165, 250, 0.05) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 85% 110%, rgba(34, 211, 238, 0.04) 0%, transparent 50%); + pointer-events: none; + z-index: 0; +} + +nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 1000; + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + padding: 0 1rem; + display: flex; + align-items: center; + box-shadow: var(--shadow-sm); + transition: all 0.3s ease; +} +nav::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--primary), var(--accent), transparent); + opacity: 0.3; +} + +.nav-menu { display: flex; align-items: center; gap: 0.25rem; } +.nav-menu a, +.nav-account a { + display: inline-flex; + align-items: center; + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + color: var(--text-primary); + text-decoration: none; +} +.nav-menu > a:first-child { + font-weight: 700; + color: var(--primary); + padding: 1rem 1.5rem; + margin: 0.5rem 0.25rem; +} +.nav-menu a:hover, +.nav-account a:hover { + background: var(--surface-hover); + color: var(--primary); +} +.nav-account { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} +.nav-account .account-name { + color: var(--text-secondary); + font-weight: 500; + font-size: 0.95rem; + margin-right: 0.5rem; +} +.nav-account .nav-avatar, +.profile-avatar { + border-radius: 999px; + object-fit: cover; + border: 1px solid var(--border); +} +.nav-account .nav-avatar { width: 30px; height: 30px; } +.profile-avatar { width: 96px; height: 96px; } + +#content { + margin-top: 5rem; + min-height: calc(100vh - 10rem); + padding: 2rem 1rem; + max-width: 1200px; + margin-left: auto; + margin-right: auto; +} + +h1, h2, h3, h4, h5, h6 { + font-weight: 700; + line-height: 1.2; + margin-bottom: 1rem; + color: var(--text-primary); +} +h1 { + font-size: 2rem; + position: relative; + padding-bottom: 0.75rem; + margin-bottom: 1.25rem; +} +h1::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 60px; + height: 3px; + background: linear-gradient(90deg, var(--primary), var(--accent)); + border-radius: 3px; +} +h2 { font-size: 2rem; } +h3 { font-size: 1.5rem; } + +#content > div:not(.ws-app), .block, .card, .dashboard-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.75rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow-sm); + transition: border-color 0.25s ease, box-shadow 0.25s ease; +} + +#content > div:not(.ws-app):hover, .block:hover, .card:hover, .dashboard-panel:hover { + box-shadow: var(--shadow-md); + border-color: var(--border-hover); +} + +form { max-width: 600px; margin: 0 auto; } +form > div { display: flex; flex-direction: column; margin-bottom: 1.5rem; gap: 0.5rem; } +label { font-weight: 500; color: var(--text-secondary); font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.05em; } +input, select, textarea { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid var(--border); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 1rem; + font-family: inherit; +} +textarea { min-height: 120px; } +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2); + background: var(--surface); +} +button, .btn, input[type="submit"] { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.5rem; + background: var(--primary); + color: var(--bg-color); + border: none; + border-radius: var(--radius); + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s ease; +} +button:hover, .btn:hover, input[type="submit"]:hover { + background: var(--primary-dark); + transform: translateY(-1px); + box-shadow: var(--shadow-lg); + text-decoration: none; +} + +.banner { + border-radius: var(--radius); + padding: 0.8rem 1rem; + border: 1px solid var(--border); + margin-bottom: 1rem; + background: rgba(255,255,255,0.03); +} +.banner.success { background: var(--success-bg); border-color: var(--success-border); color: var(--success-text); } +.banner.warning { background: var(--warning-bg); border-color: var(--warning-border); color: var(--warning-text); } +.banner.error, .error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } + +table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border); + background: rgba(255,255,255,0.02); + border-radius: var(--radius); + overflow: hidden; +} +th, td { padding: 0.8rem 0.9rem; border-bottom: 1px solid var(--border); text-align: left; } +th { background: var(--surface-elevated); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(255,255,255,0.02); } +code, pre { font-family: var(--font-mono); } +pre { + padding: 1rem; + background: rgba(15, 23, 42, 0.55); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: auto; +} + +footer { + border-top: 1px solid var(--border); + padding: 1.25rem 1rem; + color: var(--text-muted); + font-size: 0.875rem; + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +input[type="range"] { + width: 100%; + height: 6px; + background: var(--bg-secondary); + outline: none; + border: none; + border-radius: 999px; + -webkit-appearance: none; + appearance: none; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + background: var(--primary); + cursor: pointer; + border-radius: 50%; + box-shadow: 0 0 6px rgba(96, 165, 250, 0.3); +} +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--primary); + cursor: pointer; + border-radius: 50%; + border: none; + box-shadow: 0 0 6px rgba(96, 165, 250, 0.3); +} + +@media (max-width: 860px) { + nav { flex-wrap: wrap; padding-bottom: 0.6rem; } + .nav-account { width: 100%; margin-left: 0; justify-content: flex-start; } + #content { margin-top: 7.5rem; padding: 1rem 0.75rem; } + #content > div:not(.ws-app), .block, .card, .dashboard-panel { padding: 1.25rem; margin-bottom: 1rem; } + h1 { font-size: 2rem; padding: 1.25rem; } +} \ No newline at end of file diff --git a/themes/portal-dark/icon.png b/themes/portal-dark/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8a42581d4a2a2a28bee1888835d21ffe4d6378d0 GIT binary patch literal 4029 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 + + + + + + + 'nav-account nav-menu')); ?> +
> + +
+ $embed_mode)); ?> + + + \ No newline at end of file diff --git a/views/asset.php b/views/asset.php new file mode 100644 index 0000000..4e2f98f --- /dev/null +++ b/views/asset.php @@ -0,0 +1,124 @@ +resolveDocumentPath($docsetId, $relativePath); +if(!$doc) +{ + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + echo "Docset asset not found."; + exit; +} + +$file = $doc['file']; +$mime = detect_docset_mime($file); +header('Content-Type: '.$mime); +header('X-Content-Type-Options: nosniff'); + +if(str_starts_with($mime, 'text/html')) +{ + echo rewrite_docset_html((string)file_get_contents($file), $repo, $docsetId, dirname($doc['relative'])); + exit; +} + +readfile($file); +exit; + +function detect_docset_mime(string $file): string +{ + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $map = array( + 'html' => 'text/html; charset=utf-8', + 'htm' => 'text/html; charset=utf-8', + 'css' => 'text/css; charset=utf-8', + 'js' => 'application/javascript; charset=utf-8', + 'json' => 'application/json; charset=utf-8', + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'webp' => 'image/webp', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + 'ttf' => 'font/ttf', + 'ico' => 'image/x-icon', + ); + return $map[$extension] ?? 'application/octet-stream'; +} + +function rewrite_docset_html(string $html, DocsetRepository $repo, string $docsetId, string $baseDir): string +{ + if(!class_exists('DOMDocument')) + return $html; + $internalErrors = libxml_use_internal_errors(true); + $dom = new DOMDocument(); + $dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($internalErrors); + foreach(array('href', 'src') as $attribute) + { + $nodes = array(); + foreach($dom->getElementsByTagName('*') as $node) + { + if($node->hasAttribute($attribute)) + $nodes[] = $node; + } + foreach($nodes as $node) + { + $value = $node->getAttribute($attribute); + $rewritten = rewrite_docset_url($value, $repo, $docsetId, $baseDir); + if($rewritten !== $value) + $node->setAttribute($attribute, $rewritten); + } + } + return $dom->saveHTML(); +} + +function rewrite_docset_url(string $value, DocsetRepository $repo, string $docsetId, string $baseDir): string +{ + $value = trim($value); + if($value === '' || str_starts_with($value, '#')) + return $value; + $lower = strtolower($value); + foreach(array('http://', 'https://', 'mailto:', 'data:', 'javascript:', 'about:') as $prefix) + { + if(str_starts_with($lower, $prefix)) + return $value; + } + $parts = explode('#', $value, 2); + $queryParts = explode('?', $parts[0], 2); + $path = $queryParts[0]; + $anchor = $parts[1] ?? ''; + if($path === '') + return $value; + if(str_starts_with($path, '/')) + $target = ltrim($path, '/'); + else + $target = trim($baseDir === '.' ? '' : $baseDir, '/').'/'.$path; + $normalized = normalize_docset_relative_path($target); + $url = $repo->assetUrl($docsetId, $normalized); + if($anchor !== '') + $url .= '#'.rawurlencode($anchor); + return $url; +} + +function normalize_docset_relative_path(string $path): string +{ + $segments = array(); + foreach(explode('/', str_replace('\\', '/', $path)) as $segment) + { + if($segment === '' || $segment === '.') + continue; + if($segment === '..') + { + array_pop($segments); + continue; + } + $segments[] = $segment; + } + return implode('/', $segments); +} diff --git a/views/docsets.php b/views/docsets.php new file mode 100644 index 0000000..f9862fb --- /dev/null +++ b/views/docsets.php @@ -0,0 +1,24 @@ +listDocsets(); +?> +
+

Installed docsets

+

Docsets are loaded from .

+ +

No docsets installed.

+ +
+ + + +
+ +
diff --git a/views/index.php b/views/index.php new file mode 100644 index 0000000..fd499a5 --- /dev/null +++ b/views/index.php @@ -0,0 +1,72 @@ +listDocsets(); +$query = trim((string)($_GET['q'] ?? '')); +$selected = trim((string)($_GET['docset'] ?? '')); +$results = array(); +$error = ''; +if($query !== '') +{ + try + { + $results = $repo->search($query, $selected, (int)cfg('docsets/search_limit')); + } + catch(Throwable $e) + { + $error = $e->getMessage(); + Log::text('search', $error); + } +} +?> +
+
+

Local Dash/Zeal documentation

+

Search Zeal docsets

+

Browse offline docsets from data/docsets/. The test PHP docset is intentionally ignored by git.

+
+
docsets
+
+ +
+
+ + + + + +
+ +

No docsets found yet. Put *.docset directories under data/docsets/.

+ +
+ + +

Search failed

+ + + +
+

results for “”

+ +

No matching entries found.

+ +
+ + + + · + + + +
+ +
+ diff --git a/views/view.php b/views/view.php new file mode 100644 index 0000000..c09a5fa --- /dev/null +++ b/views/view.php @@ -0,0 +1,34 @@ +resolveDocumentPath($docsetId, $path); +if(!$doc) +{ + URL::NotFound('Document not found in docset.'); + URL::$route['page-title'] = 'Not Found'; + ?> +

Document not found

+ assetUrl($docsetId, $path); +if($doc['anchor'] !== '') + $assetUrl .= '#'.rawurlencode($doc['anchor']); +?> +
+
+
+

+

+
+ +
+ +