Port Docs-Viewer to slatehtml as the docs-viewer package.

Replace the classic DOM shell with slate widgets, markdown→slate rendering, and a CI index build that uses build-docs.cjs.
This commit is contained in:
2026-08-02 20:48:31 +10:00
parent 407ee6c0d3
commit 10e9f7ac2f
36 changed files with 3789 additions and 5474 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- master
- slatehtml
jobs:
build:
runs-on: ubuntu-latest
@@ -11,15 +12,15 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: "22"
- name: Build docs
run: node build-docs.js
- name: Build docs index
run: node build-docs.cjs
- name: Check for changes
id: check_changes

5
.gitignore vendored
View File

@@ -1 +1,4 @@
/node_modules
node_modules/
dist/
*.log
.DS_Store

288
README.md
View File

@@ -1,247 +1,83 @@
# Docs Viewer
A modern, accessible documentation viewer for Markdown files with live search, navigation, and mobile support.
Static documentation viewer built on **slatehtml** + **slatehtml-ui**: panel shell, live search, wiki links, and markdown as slate blocks (inline via `slate-rich-text`).
## Features
## Install (consumer sites)
- 🔍 **Live search** with keyboard shortcuts (Alt+S) and result caching
- 📱 **Mobile-friendly** responsive design with focus trap
- 🎯 **Keyboard navigation** support (arrow keys, Enter, Escape)
- 📑 **Auto-generated document outline** with collapsible headers
- 🔗 **Wiki-style internal linking** with `[[Page Title]]` syntax
- 🖨️ **Print-friendly** styling
-**ARIA-compliant accessibility** (aria-current, role attributes, focus management)
- 🌙 **Dark theme** with CSS custom properties
-**Performance optimized** with document caching and lazy loading
- 📊 **Loading indicators** with animated progress bars
## Screenshots
<p align="center">
<img alt="Home" title="Home Page" src="https://github.com/user-attachments/assets/eb353607-7ce2-47fd-be87-479d9bbdac5c" height="300" />
<img alt="Mobile UI" title="Mobile View" src="https://github.com/user-attachments/assets/b6157ec9-519a-47b6-b487-d5447f599027" height="300" />
<br/>
<img alt="Search" title="Live Search" src="https://github.com/user-attachments/assets/4ae9b7f7-2d99-4668-b6f5-5bd52c135e26" height="300" />
<img alt="Outline" title="Document Outline Sidebar" src="https://github.com/user-attachments/assets/e2d58aa1-e297-4b7e-bec1-d3be5f69f45d" height="300" />
<img alt="Print" title="Print-Friendly View" src="https://github.com/user-attachments/assets/a840be73-ca93-46c6-9fbe-5d498c2c3525" height="300" />
</p>
## Quick Start
1. Install dependencies:
```bash
npm install
npm install github:litruv/Docs-Viewer#slatehtml
# peers / siblings as needed:
npm install slatehtml slatehtml-ui
```
2. Copy the example configuration:
```bash
cp example.index.json index.json
```
Then modify `index.json` with your site's metadata, author info, and social links.
3. Create your documentation structure:
```
docs/
├── images/ # Place images here
├── index.md # Main landing page
└── ... other .md files
```
4. Build the documentation index:
```bash
npm run build
```
This will scan your docs folder and update `index.json` with the document structure.
5. Start the development server:
```bash
npm start
```
## Documentation Structure
### File Organization
- Place all documentation files in the `docs/` directory
- Store images and video in `docs/images/`
- Use `.md` extension for Markdown files
### Markdown Files
Each Markdown file can include YAML frontmatter:
```markdown
---
title: Page Title
description: Page description
sort: 1 # Optional: controls sidebar order
thumbnail: images/thumb.png # Optional: for OG images
---
# Content starts here
```
### Folder Structure
To create sections, make a folder and add a matching Markdown file:
```
docs/
├── getting-started/
│ ├── getting-started.md # Folder index
│ ├── installation.md
│ └── configuration.md
└── index.md
```
## Special Features
### Wiki Links
Use double brackets for internal links:
```markdown
[[Page Title]]
[[Page Title|Custom Text]]
```
### Images
Store images in `docs/images/` and reference them:
```markdown
![Alt text](./docs/images/picture.png)
# or
![[picture.png]]
```
### Headers
Headers are automatically added to the right sidebar outline and are collapsible.
## Development
### Recommended Editor
We recommend using [Obsidian.md](https://obsidian.md) as your editor for the documentation files. The `docs/.obsidian` directory includes a custom plugin that provides enhanced editing features:
- Displays page titles in the file explorer instead of filenames
- Shows frontmatter-defined sort order in the file list
- Automatically updates file ordering based on the `sort` property
- Makes folder and document organization more intuitive
To use the plugin:
1. Open the `docs` folder as an Obsidian vault
2. The plugin will be automatically loaded
3. The file explorer will now show your document titles and sorting order
### Project Structure
```
.
├── docs/ # Documentation files
├── js/ # Application modules
│ ├── EventBus.js # Pub-sub event system
│ ├── IndexService.js # Document index management
│ ├── SearchService.js # Search with caching
│ ├── DOMService.js # DOM manipulation & accessibility
│ ├── DocumentService.js # Markdown loading & caching
│ ├── NavigationService.js # Browser history handling
│ └── Documentation.js # Main orchestrator
├── build-docs.js # Documentation builder
├── index.html # Main viewer
└── styles.css # Styling
```
### Architecture
The application uses a modular ES6 architecture with:
- **EventBus**: Pub-sub pattern for decoupled communication between services
- **Services**: Single-responsibility modules for search, DOM, documents, and navigation
- **Caching**: LRU caches for search results (50 entries) and documents (20 entries)
- **Lazy Loading**: Images use `loading="lazy"` for improved performance
### Building
The build process:
1. Scans the `docs/` directory
2. Edits `index.json` with document metadata
A GitHub Actions workflow is included that automatically:
- Runs on every push to the master branch
- Executes the build process
- Commits and pushes any changes to `index.json`
- Ensures your documentation index stays in sync with your content
This means you can edit your documentation directly on GitHub, and the index will be automatically updated.
If you don't want to use Github actions, you can use npm run build
### Cloudflare Pages
1. Create a new repository on GitHub.
2. Push your code to the repository.
3. Go to [Cloudflare Pages](https://pages.cloudflare.com/) and connect your GitHub repository.
4. Configure the build settings:
- **Production branch:** `main` (or your main branch name)
- **Build command:** Leave empty
- **Build output directory:** `/` (root)
5. Save and deploy.
#### Optional: Cloudflare Worker for OG/Twitter Tags
For improved SEO and social sharing, you can use a Cloudflare Worker to dynamically generate OG/Twitter tags.
1. Create a new Cloudflare Worker using the code in `cloudflare-worker.js`.
2. Set the `SITE_URL` environment variable to where your site will be located, e.g., `https://example.com/docs/`
3. Set the `DOCS_URL` environment variable to the URL where your documentation files are hosted (usually your Cloudflare Pages URL).
4. Configure a route in your Cloudflare account to route all requests to your Cloudflare Pages site through the worker.
## Configuration
### index.json
Local sibling checkout:
```json
{
"defaultPage": "home",
"showDocsLink": true,
"metadata": {
"title": "Site Title",
"description": "Site description",
"site_name": "Documentation"
},
"author": {
"name": "Author Name",
"role": "Role",
"socials": [
{
"icon": "fab fa-github",
"url": "https://github.com/username",
"title": "GitHub"
}
]
"dependencies": {
"docs-viewer": "file:../Docs-Viewer",
"slatehtml": "file:../slatehtml",
"slatehtml-ui": "file:../slatehtml/packages/slatehtml-ui"
}
}
```
The `showDocsLink` option controls whether a "Docs" link is shown in the UI navigation. Set to `false` to hide it.
## App usage
The build process generates the documents part for `index.json`
## Contributing
```html
<body>
<slate-docs-viewer index="./index.json"></slate-docs-viewer>
<script type="module" src="./main.js"></script>
</body>
```
1. Fork the repository
2. Create a feature branch
3. Submit a pull request
```js
import "slatehtml";
import "slatehtml-ui/app-bar";
import "slatehtml-ui/icon";
import "slatehtml-ui/text-field";
import "slatehtml-ui/side-bar";
import "slatehtml-ui/text";
import "slatehtml-ui/progress";
import "slatehtml-ui/alert";
import "slatehtml-ui/divider";
import { configure } from "slatehtml-ui/configure";
import { fontAwesomeSvg } from "slatehtml-ui/icons/fontawesome";
import { mountDocs } from "docs-viewer";
import "docs-viewer/prose.css";
## Dependencies
configure({ icons: fontAwesomeSvg });
await mountDocs("slate-docs-viewer");
```
- [marked.js](https://marked.js.org/) - Markdown parsing
- [highlight.js](https://highlightjs.org/) - Syntax highlighting
- [Font Awesome](https://fontawesome.com/) - Icons
Vite needs the UMC plugin with UI + this packages `src`:
## License
```js
import { umc } from "slatehtml/umc/vite";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
MIT License - see LICENSE file for details.
const require = createRequire(import.meta.url);
const uiSrc = join(dirname(require.resolve("slatehtml-ui/package.json")), "src");
const docsSrc = join(dirname(require.resolve("docs-viewer/package.json")), "src");
export default {
plugins: [umc({ roots: [uiSrc, docsSrc] })],
};
```
## Develop this repo
```bash
npm install # expects ../slatehtml checkout
npm run build:index
npm run dev
```
## Content contract
Same as before:
- `docs/**/*.md` + YAML frontmatter
- `index.json` metadata; `documents` from `npm run build:index`
- Wiki `[[Page Title]]`, embeds `![[image.png]]`

24
demo/main.js Normal file
View File

@@ -0,0 +1,24 @@
import "slatehtml";
import "slatehtml-ui/icon";
import "slatehtml-ui/side-bar";
import "slatehtml-ui/progress";
import "slatehtml-ui/alert";
import "slatehtml-ui/divider";
import "slatehtml-ui/bottom-nav";
import "slatehtml-ui/bottom-nav-item";
import "slatehtml-ui/collapse";
import { configure } from "slatehtml-ui/configure";
import { fontAwesomeSvg } from "slatehtml-ui/icons/fontawesome";
import { mountDocs } from "../src/index.js";
import "../src/prose.css";
import "./site.css";
configure({
icons: fontAwesomeSvg,
iconSize: "15",
});
await mountDocs("slate-docs-viewer", { indexUrl: "./index.json" });
document.documentElement.setAttribute("data-ready", "");

26
demo/site.css Normal file
View File

@@ -0,0 +1,26 @@
/* Match live Docs-Viewer tokens (lit.ruv.wtf/docs). */
:root {
--bg: #1a1a1a;
--ink: #e0e0e0;
--muted: #a0a0a0;
--panel: #252526;
--line: #404040;
--accent: #2196f3;
--docs-accent: #2196f3;
--docs-panel: #252526;
--docs-line: #404040;
--docs-muted: #a0a0a0;
--slate-font: system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans",
sans-serif;
--slate-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color-scheme: dark;
}
body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--ink);
font-family: var(--slate-font);
}

View File

@@ -1,179 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title></title>
<!--ogmetadata-->
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github-dark.min.css">
</head>
<body>
<!-- Add skip to content link -->
<a href="#document-content" class="skip-to-content">Skip to content</a>
<div class="container">
<div class="title-bar" role="banner">
<button class="menu-button" aria-label="Toggle Menu" aria-expanded="false" aria-controls="left-sidebar">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<div class="title-text">
<img src="./img/logo.png" alt="Logo" class="brand-logo">
<span class="divider" aria-hidden="true">/</span>
<span class="page-title">Documentation</span>
</div>
<span style="width: 24px;"></span>
</div>
<div class="content-container">
<nav class="sidebar left-sidebar" id="left-sidebar" role="navigation" aria-label="Main Navigation">
<div class="search-container">
<div class="search-box" role="search">
<i class="fas fa-search" aria-hidden="true"></i>
<input type="text" id="search-input" placeholder="Search docs..." aria-label="Search documentation" role="searchbox">
<div class="keyboard-shortcut keyboard-shortcut-alt" aria-hidden="true">Alt+S</div>
<i class="fas fa-times" id="clear-search" aria-label="Clear search" role="button" tabindex="0"></i>
</div>
<div id="search-results" role="region" aria-label="Search results" aria-live="polite"></div>
</div>
<div id="file-index" role="tree" aria-label="Documentation files"></div>
<div class="subtitle" aria-label="Author information">
<span class="name"></span>
<span class="role"></span>
</div>
<div class="social-links" aria-label="Social media links"></div>
<div class="github-link">
<a href="https://github.com/litruv/docs" target="_blank" title="Docs Viewer GitHub" aria-label="GitHub repository for Docs Viewer">
<i class="fab fa-github" aria-hidden="true"></i> Docs Viewer
</a>
</div>
</nav>
<main class="content" role="main" aria-label="Document Content">
<div id="document-content" tabindex="-1"></div>
</main>
<nav class="sidebar right-sidebar" role="navigation" aria-label="Table of Contents">
<div id="document-outline" role="tree" aria-label="Document outline"></div>
</nav>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked@4.3.0/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script>
hljs.highlightAll();
</script>
<script type="module" src="js/Documentation.js"></script>
<!-- Print title capture script -->
<script>
// Capture page title for printing
window.addEventListener('beforeprint', function() {
// Get the current page title
const pageTitle = document.querySelector('.title-text .page-title').textContent;
const brandLogo = document.querySelector('.brand-logo').cloneNode(true);
// Create print header with logo
const printHeader = document.createElement('div');
printHeader.className = 'print-header';
// Add the logo image
printHeader.appendChild(brandLogo);
// Add the divider and page title
const titleText = document.createElement('div');
titleText.className = 'print-title-text';
titleText.innerHTML = `<span class="divider">/</span> <span class="page-title">${pageTitle}</span>`;
printHeader.appendChild(titleText);
// Insert at the beginning of content
const content = document.getElementById('document-content');
if (content.firstChild) {
content.insertBefore(printHeader, content.firstChild);
} else {
content.appendChild(printHeader);
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Docs Viewer</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #1e1e2e;
color: #c6d0f5;
}
// Remove any existing print elements to avoid duplication
const existingTitle = document.querySelector('.print-title');
if (existingTitle) existingTitle.remove();
// Make sure the print header is accessible
brandLogo.setAttribute('alt', 'Logo');
printHeader.setAttribute('aria-hidden', 'true'); // Hide from screen readers when printing
});
// Clean up after printing
window.addEventListener('afterprint', function() {
const printHeader = document.querySelector('.print-header');
if (printHeader) {
printHeader.remove();
html:not([data-ready]) {
visibility: hidden;
}
});
</script>
<!-- Add accessibility enhancements script -->
<script>
// Enhance keyboard navigation
document.addEventListener('DOMContentLoaded', function() {
// Make folders and navigation keyboard accessible
function enhanceTreeKeyboardNavigation() {
const folders = document.querySelectorAll('.folder-header');
const links = document.querySelectorAll('#file-index a, #document-outline a');
folders.forEach(folder => {
// Add ARIA attributes
const folderDiv = folder.closest('.folder');
const isOpen = folderDiv.classList.contains('open');
folder.setAttribute('role', 'treeitem');
folder.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
// Handle keyboard events
folder.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
folder.click();
folder.setAttribute('aria-expanded',
folderDiv.classList.contains('open') ? 'true' : 'false');
}
});
folder.setAttribute('tabindex', '0');
});
links.forEach(link => {
link.setAttribute('role', 'treeitem');
});
}
// Handle menu button accessibility
const menuButton = document.querySelector('.menu-button');
if (menuButton) {
menuButton.addEventListener('click', function() {
const expanded = document.querySelector('.left-sidebar').classList.contains('show');
menuButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
}
// If using a MutationObserver to watch for DOM changes, call enhanceTreeKeyboardNavigation
// after the navigation tree is populated
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length) {
enhanceTreeKeyboardNavigation();
}
});
});
observer.observe(document.getElementById('file-index'), {
childList: true,
subtree: true
});
// Initially enhance any existing navigation
enhanceTreeKeyboardNavigation();
});
</script>
</body>
</style>
</head>
<body>
<slate-docs-viewer index="./index.json"></slate-docs-viewer>
<script type="module" src="./demo/main.js"></script>
</body>
</html>

37
index.json Normal file
View File

@@ -0,0 +1,37 @@
{
"defaultPage": "home",
"customCSS": "custom/theme.css",
"metadata": {
"title": "Project Documentation",
"description": "Comprehensive documentation for various projects.",
"thumbnail": "img/default-thumbnail.png",
"site_name": "DocsHub"
},
"author": {
"name": "John Doe",
"role": "Software Engineer",
"socials": [
{
"icon": "fab fa-github",
"url": "https://github.com/johndoe",
"title": "GitHub - JohnDoe"
},
{
"icon": "fab fa-youtube",
"url": "https://youtube.com/c/JohnDoe",
"title": "YouTube - JohnDoe"
},
{
"icon": "fab fa-discord",
"url": "https://discordapp.com/users/1234567890",
"title": "Discord - @JohnDoe"
},
{
"icon": "fa-brands fa-bluesky",
"url": "https://bsky.app/profile/john.doe.dev",
"title": "Bluesky - john.doe.dev"
}
]
},
"documents": []
}

View File

@@ -1,576 +0,0 @@
/**
* Shows or hides the Docs link in the sidebar based on config.
* @param {boolean} show - Whether to show the Docs link.
*/
setShowDocsLink(show) {
const docsLink = document.querySelector('.github-link');
if (docsLink) {
docsLink.style.display = show ? '' : 'none';
}
}
/**
* Service responsible for DOM manipulation and UI rendering.
* @class DOMService
*/
export class DOMService {
/**
* Creates a new DOMService instance.
* @param {EventBus} eventBus - The event bus for communication.
*/
constructor(eventBus) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {DOMElements} */
this.elements = {
content: document.getElementById('document-content'),
outline: document.getElementById('document-outline'),
fileIndex: document.getElementById('file-index'),
titleText: document.querySelector('.title-text .page-title'),
leftSidebar: document.querySelector('.left-sidebar'),
menuButton: document.querySelector('.menu-button'),
header: document.querySelector('title-bar'),
searchInput: document.getElementById('search-input'),
searchResults: document.getElementById('search-results'),
clearSearch: document.getElementById('clear-search')
};
/** @type {number} */
this.headerOffset = 60;
}
/**
* Sets up the mobile menu toggle functionality.
*/
setupMobileMenu() {
this.elements.menuButton.addEventListener('click', () => {
const isExpanded = this.elements.leftSidebar.classList.toggle('show');
this.elements.menuButton.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (isExpanded) {
this.enableFocusTrap();
} else {
this.disableFocusTrap();
}
});
this.elements.content.addEventListener('click', () => {
this.elements.leftSidebar.classList.remove('show');
this.elements.menuButton.setAttribute('aria-expanded', 'false');
this.disableFocusTrap();
});
}
/**
* Enables focus trap within the mobile sidebar.
* @private
*/
enableFocusTrap() {
this.focusTrapHandler = (e) => {
if (window.innerWidth > 1000) return;
if (!this.elements.leftSidebar.classList.contains('show')) return;
const focusableElements = this.elements.leftSidebar.querySelectorAll(
'a[href], button, input, [tabindex]:not([tabindex="-1"])'
);
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
if (e.key === 'Escape') {
this.elements.leftSidebar.classList.remove('show');
this.elements.menuButton.setAttribute('aria-expanded', 'false');
this.elements.menuButton.focus();
this.disableFocusTrap();
}
};
document.addEventListener('keydown', this.focusTrapHandler);
const firstFocusable = this.elements.leftSidebar.querySelector(
'a[href], button, input, [tabindex]:not([tabindex="-1"])'
);
if (firstFocusable) {
firstFocusable.focus();
}
}
/**
* Disables the focus trap.
* @private
*/
disableFocusTrap() {
if (this.focusTrapHandler) {
document.removeEventListener('keydown', this.focusTrapHandler);
this.focusTrapHandler = null;
}
}
/**
* Sets the main content area HTML.
* @param {string} html - The HTML content to render.
*/
setContent(html) {
this.elements.content.innerHTML = html;
this.elements.content.className = 'markdown-content';
hljs.highlightAll();
}
/**
* Sets the document title and page title display.
* @param {string} title - The title to set.
*/
setTitle(title) {
document.title = `${window.originalDocTitle} / ${title}`;
this.elements.titleText.textContent = title;
}
/**
* Displays an error message in the content area.
* @param {string} message - The error message to display.
*/
setError(message) {
this.elements.content.innerHTML = `<div class="error">${message}</div>`;
}
/**
* Creates a file index item (folder or file) in the sidebar.
* @param {Document} doc - The document to create an item for.
* @param {HTMLElement} container - The container element to append to.
* @param {number} [level=0] - The nesting level for indentation.
*/
createFileIndexItem(doc, container, level = 0) {
if (doc.type === 'folder') {
this.createFolderItem(doc, container, level);
} else {
this.createFileItem(doc, container, level);
}
}
/**
* Creates a folder item in the sidebar.
* @param {Document} doc - The folder document.
* @param {HTMLElement} container - The container element.
* @param {number} level - The nesting level.
* @private
*/
createFolderItem(doc, container, level) {
const folderDiv = document.createElement('div');
const isOpen = doc.defaultOpen === true;
folderDiv.className = 'folder' + (isOpen ? ' open' : '');
folderDiv.dataset.path = doc.title;
folderDiv.style.paddingLeft = `${level * 0.8}rem`;
const folderHeader = document.createElement('div');
folderHeader.className = 'folder-header';
folderHeader.setAttribute('role', 'treeitem');
folderHeader.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
folderHeader.setAttribute('tabindex', '0');
const iconClass = doc.icon || `fas fa-folder${isOpen ? '-open' : ''}`;
if (doc.path && doc.metadata?.showfolderpage !== false) {
folderHeader.innerHTML = this.createFolderHeaderWithFile(iconClass, doc);
this.setupFolderListeners(folderDiv, folderHeader, doc);
} else {
folderHeader.innerHTML = this.createFolderHeaderBasic(iconClass, doc);
this.setupBasicFolderListeners(folderDiv, folderHeader, doc);
}
const folderContent = document.createElement('div');
folderContent.className = 'folder-content';
doc.items.forEach(item => this.createFileIndexItem(item, folderContent, level + 1));
folderDiv.appendChild(folderHeader);
folderDiv.appendChild(folderContent);
container.appendChild(folderDiv);
}
/**
* Sets up listeners for basic folder toggle (no folder page).
* @param {HTMLElement} folderDiv - The folder container element.
* @param {HTMLElement} folderHeader - The folder header element.
* @param {Document} doc - The folder document.
* @private
*/
setupBasicFolderListeners(folderDiv, folderHeader, doc) {
folderHeader.addEventListener('click', () => {
folderDiv.classList.toggle('open');
const isExpanded = folderDiv.classList.contains('open');
folderHeader.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (!doc.icon) {
const icon = folderHeader.querySelector('.folder-icon');
icon.classList.toggle('fa-folder-closed');
icon.classList.toggle('fa-folder-open');
}
});
folderHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
folderHeader.click();
}
});
}
/**
* Creates a file item link in the sidebar.
* @param {Document} doc - The file document.
* @param {HTMLElement} container - The container element.
* @param {number} level - The nesting level.
* @private
*/
createFileItem(doc, container, level) {
const link = document.createElement('a');
link.href = `?${doc.slug}`;
link.textContent = doc.title || doc.path.split('/').pop().replace('.md', '');
link.dataset.path = doc.path;
link.dataset.slug = doc.slug;
link.style.paddingLeft = `${(level * 0.6) + 0.8}rem`;
link.setAttribute('role', 'treeitem');
link.onclick = (e) => {
e.preventDefault();
this.setLinkLoading(link);
this.eventBus.emit('navigation:requested', { slug: doc.slug });
history.pushState(null, '', link.href);
if (window.innerWidth <= 1000) {
this.elements.leftSidebar.classList.remove('show');
}
};
container.appendChild(link);
}
/**
* Sets a link to loading state.
* @param {HTMLAnchorElement} link - The link element.
*/
setLinkLoading(link) {
this.clearAllLoading();
link.classList.add('loading');
}
/**
* Clears loading state from all links.
*/
clearAllLoading() {
this.elements.fileIndex.querySelectorAll('a.loading, .folder-link.loading').forEach(link => {
link.classList.remove('loading');
});
}
/**
* Updates the active document highlight in the sidebar.
* @param {string} path - The path of the active document.
*/
updateActiveDocument(path) {
this.clearAllLoading();
this.elements.fileIndex.querySelectorAll('a').forEach(link => {
const isActive = link.dataset.path === path;
link.classList.toggle('active', isActive);
if (isActive) {
link.setAttribute('aria-current', 'page');
} else {
link.removeAttribute('aria-current');
}
});
}
/**
* Creates the document outline from headings.
* @param {NodeListOf<HTMLHeadingElement>} headings - The heading elements.
* @returns {Map<HTMLHeadingElement, HTMLAnchorElement>} Map of headings to their outline links.
*/
createOutline(headings) {
this.elements.outline.innerHTML = '';
const headingLinks = new Map();
headings.forEach(heading => {
if (!heading.id) {
heading.id = heading.textContent.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
const link = this.createOutlineLink(heading);
headingLinks.set(heading, link);
this.elements.outline.appendChild(link);
this.addHeadingFoldToggle(heading);
});
return headingLinks;
}
/**
* Creates HTML for a folder header with a file link.
* @param {string} iconClass - The icon CSS class.
* @param {Document} doc - The folder document.
* @returns {string} The HTML string.
* @private
*/
createFolderHeaderWithFile(iconClass, doc) {
const showFolderPage = doc.showfolderpage !== 'false';
return `
<div class="folder-icons">
<i class="${iconClass} folder-icon" aria-hidden="true"></i>
</div>
<span>${doc.title}</span>
${showFolderPage ? `
<a href="?${doc.slug}" class="folder-link" title="View folder page" aria-label="View ${doc.title} folder page">
<i class="fas fa-file-alt" aria-hidden="true"></i>
</a>` : ''}`;
}
/**
* Creates HTML for a basic folder header without file link.
* @param {string} iconClass - The icon CSS class.
* @param {Document} doc - The folder document.
* @returns {string} The HTML string.
* @private
*/
createFolderHeaderBasic(iconClass, doc) {
return `
<div class="folder-icons">
<i class="${iconClass} folder-icon" aria-hidden="true"></i>
</div>
<span>${doc.title}</span>`;
}
/**
* Sets up event listeners for a folder with a navigable page.
* @param {HTMLElement} folderDiv - The folder container element.
* @param {HTMLElement} folderHeader - The folder header element.
* @param {Document} doc - The folder document.
* @private
*/
setupFolderListeners(folderDiv, folderHeader, doc) {
folderHeader.addEventListener('click', (e) => {
if (e.target.closest('.folder-link')) {
e.preventDefault();
const folderLink = e.target.closest('.folder-link');
this.setFolderLinkLoading(folderLink);
this.eventBus.emit('navigation:requested', { slug: doc.slug });
history.pushState(null, '', `?${doc.slug}`);
if (window.innerWidth <= 1000) {
this.elements.leftSidebar.classList.remove('show');
}
return;
}
folderDiv.classList.toggle('open');
const isExpanded = folderDiv.classList.contains('open');
folderHeader.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
if (!doc.icon) {
const icon = folderHeader.querySelector('.folder-icon');
icon.classList.toggle('fa-folder-closed');
icon.classList.toggle('fa-folder-open');
}
});
folderHeader.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (e.target.closest('.folder-link')) {
e.target.closest('.folder-link').click();
} else {
folderHeader.click();
}
}
});
}
/**
* Sets a folder link to loading state.
* @param {HTMLAnchorElement} link - The folder link element.
*/
setFolderLinkLoading(link) {
this.clearAllLoading();
link.classList.add('loading');
}
/**
* Scrolls smoothly to an element with header offset.
* @param {HTMLElement} element - The element to scroll to.
*/
scrollToElement(element) {
if (!element) return;
const rect = element.getBoundingClientRect();
const absoluteElementTop = rect.top + window.scrollY;
const middle = absoluteElementTop - (this.headerOffset + 20);
window.scrollTo({
top: middle,
behavior: 'smooth'
});
}
/**
* Creates an outline link for a heading.
* @param {HTMLHeadingElement} heading - The heading element.
* @returns {HTMLAnchorElement} The created link element.
* @private
*/
createOutlineLink(heading) {
const link = document.createElement('a');
link.href = `${window.location.pathname}${window.location.search}#${heading.id}`;
link.textContent = heading.textContent;
link.style.paddingLeft = (heading.tagName[1] * 15) + 'px';
link.onclick = (e) => {
e.preventDefault();
history.pushState(null, '', link.href);
this.scrollToElement(heading);
heading.classList.remove('highlight');
void heading.offsetWidth;
heading.classList.add('highlight');
};
return link;
}
/**
* Adds a fold/unfold toggle button to a heading.
* @param {HTMLHeadingElement} heading - The heading element.
* @private
*/
addHeadingFoldToggle(heading) {
const toggleBtn = document.createElement('span');
toggleBtn.innerHTML = `<svg width="10" height="10" viewBox="0 0 10 10" style="transform: rotate(90deg); transition: transform 0.2s;">
<path d="M3 2L7 5L3 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
toggleBtn.style.cursor = 'pointer';
toggleBtn.style.userSelect = 'none';
toggleBtn.style.marginLeft = '0.5em';
toggleBtn.style.display = 'inline-flex';
toggleBtn.style.alignItems = 'center';
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const svg = toggleBtn.querySelector('svg');
const isFolded = svg.style.transform === 'rotate(90deg)';
svg.style.transform = isFolded ? 'rotate(0deg)' : 'rotate(90deg)';
let next = heading.nextElementSibling;
const currentLevel = parseInt(heading.tagName[1]);
while (next) {
if (!/^H[1-6]$/.test(next.tagName)) {
next.style.display = isFolded ? 'none' : '';
next = next.nextElementSibling;
} else {
const nextLevel = parseInt(next.tagName[1]);
if (nextLevel <= currentLevel) break;
next.style.display = isFolded ? 'none' : '';
next = next.nextElementSibling;
}
}
});
heading.appendChild(toggleBtn);
}
/**
* Sets up search input event handlers.
* @param {SearchService} searchService - The search service instance.
*/
setupSearch(searchService) {
let searchTimeout;
this.elements.searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value;
searchTimeout = setTimeout(() => {
const results = searchService.search(query);
this.renderSearchResults(results);
}, 200);
});
this.elements.clearSearch.addEventListener('click', () => {
this.elements.searchInput.value = '';
this.elements.searchResults.innerHTML = '';
this.elements.searchResults.style.display = 'none';
this.elements.searchResults.setAttribute('aria-hidden', 'true');
});
}
/**
* Renders search results in the dropdown.
* @param {Array<SearchResult>} results - The search results to render.
*/
renderSearchResults(results) {
const container = this.elements.searchResults;
container.innerHTML = '';
if (results.length === 0 || !this.elements.searchInput.value) {
container.style.display = 'none';
container.setAttribute('aria-hidden', 'true');
return;
}
container.setAttribute('aria-hidden', 'false');
const resultCount = results.length === 1 ? '1 search result found' : `${results.length} search results found`;
container.setAttribute('aria-label', resultCount);
results.forEach(result => {
const div = document.createElement('div');
div.className = 'search-result';
const icon = document.createElement('i');
icon.className = result.type === 'folder' ? 'fas fa-folder' :
result.type === 'header' ? 'fas fa-hashtag' : 'fas fa-file-alt';
const link = document.createElement('a');
link.href = `?${result.slug}`;
link.innerHTML = `
${icon.outerHTML}
<div class="search-result-content">
<div class="search-result-title">${result.title}</div>
<div class="search-result-path">${result.location}</div>
</div>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
this.elements.searchInput.value = '';
container.style.display = 'none';
const [baseSlug, hash] = result.slug.split('#');
history.pushState(null, '', link.href);
this.eventBus.emit('navigation:requested', {
slug: baseSlug,
hash: hash ? `#${hash}` : '',
fromSearch: true
});
});
div.appendChild(link);
container.appendChild(div);
});
container.style.display = 'block';
}
}
/**
* @typedef {Object} DOMElements
* @property {HTMLElement} content - The main content container.
* @property {HTMLElement} outline - The document outline container.
* @property {HTMLElement} fileIndex - The file index container.
* @property {HTMLElement} titleText - The page title element.
* @property {HTMLElement} leftSidebar - The left sidebar element.
* @property {HTMLElement} menuButton - The mobile menu button.
* @property {HTMLElement} header - The header element.
* @property {HTMLInputElement} searchInput - The search input field.
* @property {HTMLElement} searchResults - The search results container.
* @property {HTMLElement} clearSearch - The clear search button.
*/

View File

@@ -1,303 +0,0 @@
/**
* Service responsible for loading and processing markdown documents.
* @class DocumentService
*/
export class DocumentService {
/**
* Creates a new DocumentService instance.
* @param {EventBus} eventBus - The event bus for communication.
* @param {IndexService} indexService - The index service for document lookups.
*/
constructor(eventBus, indexService) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {IndexService} */
this.indexService = indexService;
/** @type {Promise<marked>} */
this.markedPromise = this.initializeMarked();
/** @type {Map<string, LoadedDocument>} */
this.documentCache = new Map();
/** @type {number} */
this.cacheMaxSize = 20;
}
/**
* Initializes the marked library with custom renderer.
* @returns {Promise<marked>} Promise resolving to the marked instance.
* @private
*/
async initializeMarked() {
const marked = await new Promise((resolve) => {
if (typeof window.marked !== 'undefined') {
resolve(window.marked);
} else {
window.addEventListener('load', () => resolve(window.marked));
}
});
const renderer = new marked.Renderer();
this.setupRenderer(renderer);
marked.setOptions({
breaks: true,
gfm: true,
renderer: renderer
});
return marked;
}
/**
* Configures the marked renderer with custom handlers.
* @param {marked.Renderer} renderer - The renderer to configure.
* @private
*/
setupRenderer(renderer) {
const originalLink = renderer.link.bind(renderer);
const originalImage = renderer.image.bind(renderer);
renderer.code = this.renderCode;
renderer.image = (href, title, text) => {
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${href}" alt="${text}"${titleAttr} loading="lazy">`;
};
renderer.link = (href, title, text) => {
const isExternal = href.startsWith('http');
const attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const link = originalLink(href, title, text);
if (!isExternal && href.startsWith('?')) {
return link.replace(/^<a /, '<a data-internal="true" ');
}
return link.replace(/^<a /, `<a${attrs} `);
};
const originalHeading = renderer.heading.bind(renderer);
renderer.heading = (text, level) => {
const escapedText = text.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
const id = escapedText;
return `<h${level} id="${id}" class="clickable-header">
${text}
</h${level}>`;
};
}
/**
* Renders a code block with syntax highlighting.
* @param {string} code - The code content.
* @param {string} language - The language for highlighting.
* @returns {string} The rendered HTML.
* @private
*/
renderCode(code, language) {
let highlighted;
if (language && hljs.getLanguage(language)) {
highlighted = hljs.highlight(code, { language }).value;
} else {
highlighted = hljs.highlightAuto(code).value;
language = '';
}
return `<pre class="hljs ${language ? "language-" + language : ""}"><code>${highlighted}</code></pre>`;
}
/**
* Extracts frontmatter metadata from document content.
* @param {string} content - The raw document content.
* @returns {ExtractedDocument} The extracted metadata and content.
*/
extractMetadata(content) {
const lines = content.trim().split('\n');
let metadata = {};
let contentStart = 0;
if (lines[0].trim() === '---') {
let endMetadata = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
if (endMetadata !== -1) {
const frontmatterEntries = lines.slice(1, endMetadata)
.map(line => line.match(/^([\w-]+):\s*(.*)$/))
.filter(Boolean)
.map(([, key, value]) => {
if (key === 'defaultOpen') {
return [key, value.trim().toLowerCase() === 'true'];
} else if (key === 'sort') {
return [key, parseInt(value.trim(), 10)];
} else {
return [key, value.trim()];
}
});
metadata = Object.fromEntries(frontmatterEntries);
contentStart = endMetadata + 1;
}
}
return {
metadata,
content: lines.slice(contentStart).join('\n').trim()
};
}
/**
* Loads and processes a document from a path.
* @param {string} path - The document path to load.
* @returns {Promise<LoadedDocument>} The loaded document data.
* @throws {Error} If the document fails to load.
*/
async loadDocument(path) {
if (this.documentCache.has(path)) {
return this.documentCache.get(path);
}
try {
const [response, marked] = await Promise.all([
fetch(path),
this.markedPromise
]);
let rawContent = await response.text();
const { metadata, content } = this.extractMetadata(rawContent);
if (metadata.defaultOpen !== undefined) {
metadata.defaultOpen = metadata.defaultOpen === true || metadata.defaultOpen === 'true';
}
if (metadata.sort !== undefined) {
metadata.sort = typeof metadata.sort === 'number' ? metadata.sort : parseInt(metadata.sort, 10);
}
const basePath = path.substring(0, path.lastIndexOf('/'));
const indexDoc = this.findDocInIndex(path);
let processedContent = this.processWikiLinks(content);
processedContent = this.processImages(processedContent, basePath);
const titleContent = metadata.title || indexDoc?.title || path.split('/').pop().replace('.md', '');
processedContent = this.ensureTitle(processedContent, titleContent);
const result = {
content: processedContent,
metadata,
marked,
title: titleContent
};
if (this.documentCache.size >= this.cacheMaxSize) {
const firstKey = this.documentCache.keys().next().value;
this.documentCache.delete(firstKey);
}
this.documentCache.set(path, result);
return result;
} catch (error) {
throw new Error('Failed to load document');
}
}
/**
* Finds a document in the global index by path.
* @param {string} path - The document path.
* @returns {Document|null} The found document or null.
* @private
*/
findDocInIndex(path) {
let doc = window._indexData.documents.find(d => d.path === path);
if (!doc) {
for (const d of window._indexData.documents) {
if (d.type === 'folder' && d.items) {
doc = d.items.find(item => item.path === path);
if (doc) break;
}
}
}
return doc;
}
/**
* Ensures the content has a title heading.
* @param {string} content - The document content.
* @param {string} title - The title to add.
* @returns {string} The content with title.
* @private
*/
ensureTitle(content, title) {
content = content.replace(/^#\s+.*$/m, '').trim();
return `# ${title}\n\n${content}`;
}
/**
* Processes wiki-style links [[Link]] into markdown links.
* @param {string} content - The content to process.
* @returns {string} The processed content.
* @private
*/
processWikiLinks(content) {
return content.replace(/\[\[(.*?)\]\]/g, (match, linkText) => {
const [targetTitle, displayText] = linkText.split('|').map(s => s.trim());
if (targetTitle.match(/\.(png|jpg|jpeg|gif|mp4|webm)$/i)) {
return match;
}
const doc = this.indexService.findDocumentByTitle(
window._indexData.documents,
targetTitle
);
return doc ? `[${displayText || doc.title}](?${doc.slug})` : match;
});
}
/**
* Processes wiki-style image embeds into HTML.
* @param {string} content - The content to process.
* @param {string} basePath - The base path for relative images.
* @returns {string} The processed content.
* @private
*/
processImages(content, basePath) {
return content.replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
const mediaPath = `./docs/images/${filename}`;
if (filename.toLowerCase().endsWith('.mp4')) {
return `\n<video controls width="100%" preload="metadata">
<source src="${mediaPath}" type="video/mp4">
Your browser does not support the video tag.
</video>\n\n`;
}
return `\n<img src="${mediaPath}" alt="${filename}" loading="lazy">\n\n`;
});
}
/**
* Clears the entire document cache.
*/
clearCache() {
this.documentCache.clear();
}
/**
* Invalidates a specific document from the cache.
* @param {string} path - The document path to invalidate.
*/
invalidateDocument(path) {
this.documentCache.delete(path);
}
}
/**
* @typedef {Object} ExtractedDocument
* @property {Object} metadata - The document metadata.
* @property {string} content - The document content without frontmatter.
*/
/**
* @typedef {Object} LoadedDocument
* @property {string} content - The processed markdown content.
* @property {Object} metadata - The document metadata.
* @property {marked} marked - The marked instance.
* @property {string} title - The document title.
*/

View File

@@ -1,381 +0,0 @@
import { EventBus } from './EventBus.js';
import { IndexService } from './IndexService.js';
import { SearchService } from './SearchService.js';
import { DOMService } from './DOMService.js';
import { DocumentService } from './DocumentService.js';
import { NavigationService } from './NavigationService.js';
/**
* Main documentation application class that orchestrates all services.
* @class Documentation
*/
class Documentation {
constructor() {
/** @type {EventBus} */
this.eventBus = new EventBus();
/** @type {IndexService} */
this.indexService = new IndexService();
/** @type {SearchService} */
this.searchService = new SearchService(this.eventBus, this.indexService);
/** @type {DOMService} */
this.domService = new DOMService(this.eventBus);
/** @type {DocumentService} */
this.documentService = new DocumentService(this.eventBus, this.indexService);
/** @type {NavigationService} */
this.navigationService = new NavigationService(this.eventBus, this.documentService);
/** @type {IndexData|null} */
this.indexData = null;
this.setupEventListeners();
this.setupKeyboardShortcuts();
}
/**
* Loads a custom CSS file if specified in the index.json.
* @param {string} customCSSPath - Path to the custom CSS file relative to document root.
*/
loadCustomCSS(customCSSPath) {
if (!customCSSPath) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = customCSSPath;
document.head.appendChild(link);
}
/**
* Sets up application-wide event listeners.
* @private
*/
setupEventListeners() {
this.eventBus.on('navigation:requested', async ({ slug, hash }) => {
slug = slug.replace(/^[?=]/, '');
await this.loadDocumentBySlug(slug, hash);
});
this.eventBus.on('document:load', async ({ path }) => {
await this.loadDocument(path);
});
window.addEventListener('load', () => {
this.domService.setupMobileMenu();
this.initialize();
});
document.addEventListener('click', async (e) => {
const target = e.target.closest('a[data-internal="true"]');
if (target) {
e.preventDefault();
const slug = target.href.split('?').pop();
history.pushState(null, '', target.href);
await this.loadDocumentBySlug(slug);
}
});
}
/**
* Sets up keyboard shortcuts for navigation and search.
* @private
*/
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
const isTyping = ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) ||
document.activeElement.isContentEditable;
if (this.isSearchShortcut(e) && !isTyping) {
e.preventDefault();
this.focusSearch();
}
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && e.altKey && !isTyping) {
e.preventDefault();
this.navigatePages(e.key === 'ArrowDown' ? 1 : -1);
}
});
}
/**
* Checks if the key event is a search shortcut.
* @param {KeyboardEvent} e - The keyboard event.
* @returns {boolean} True if it's a search shortcut.
* @private
*/
isSearchShortcut(e) {
return ((e.key === 's' || e.key === 'S') && !e.ctrlKey && !e.metaKey &&
(!e.altKey || (e.altKey && (e.key === 's' || e.key === 'S'))));
}
/**
* Focuses the search input and opens mobile sidebar if needed.
* @private
*/
focusSearch() {
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.focus();
const leftSidebar = document.querySelector('.left-sidebar');
const menuButton = document.querySelector('.menu-button');
if (window.innerWidth <= 1000 && leftSidebar && !leftSidebar.classList.contains('show')) {
leftSidebar.classList.add('show');
if (menuButton) {
menuButton.setAttribute('aria-expanded', 'true');
}
}
}
}
/**
* Navigates to the next or previous page in the file index.
* @param {number} direction - 1 for next, -1 for previous.
* @private
*/
navigatePages(direction) {
const fileLinks = Array.from(document.querySelectorAll('#file-index a'));
if (fileLinks.length === 0) return;
const activeLink = document.querySelector('#file-index a.active');
if (!activeLink) return;
const activeIndex = fileLinks.indexOf(activeLink);
if (activeIndex === -1) return;
let targetIndex;
if (direction === 1) {
targetIndex = activeIndex < fileLinks.length - 1 ? activeIndex + 1 : 0;
} else {
targetIndex = activeIndex > 0 ? activeIndex - 1 : fileLinks.length - 1;
}
const targetLink = fileLinks[targetIndex];
if (targetLink) {
targetLink.click();
targetLink.scrollIntoView({ block: 'nearest' });
}
}
/**
* Initializes the documentation application.
* @returns {Promise<void>}
*/
async initialize() {
try {
const response = await fetch('index.json');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
this.indexData = data;
window._indexData = data;
if (data.customCSS) {
this.loadCustomCSS(data.customCSS);
}
// Show/hide Docs link in sidebar
this.domService.setShowDocsLink(data.showDocsLink !== false);
this.searchService.buildSearchIndex(this.indexData.documents);
this.domService.setupSearch(this.searchService);
this.populateAuthorInfo(data.author);
window.originalDocTitle = data.metadata.site_name || 'Documentation';
document.title = window.originalDocTitle;
this.domService.elements.fileIndex.innerHTML = '';
this.indexData.documents.forEach(doc =>
this.domService.createFileIndexItem(doc, this.domService.elements.fileIndex));
const search = window.location.search;
const slug = search === '' || search === '?'
? this.indexData.defaultPage
: search.replace(/^\?/, '');
await this.loadDocumentBySlug(slug);
if (window.location.hash) {
setTimeout(() => {
const element = document.getElementById(window.location.hash.slice(1));
if (element) this.domService.scrollToElement(element);
}, 100);
}
} catch (error) {
this.domService.setError(`Failed to load documentation index: ${error.message}`);
}
}
/**
* Populates the author information in the sidebar.
* @param {Author} author - The author data.
*/
populateAuthorInfo(author) {
const subtitleName = document.querySelector('.name');
const subtitleRole = document.querySelector('.role');
if (!subtitleName || !subtitleRole) return;
subtitleName.textContent = author.name || '';
subtitleRole.textContent = author.role || '';
const socials = document.querySelector('.social-links');
if (socials) {
socials.innerHTML = '';
if (author.socials) {
author.socials.forEach(s => {
const link = document.createElement('a');
link.href = s.url;
link.target = '_blank';
link.title = s.title;
link.innerHTML = `<i class="${s.icon}"></i>`;
socials.appendChild(link);
});
}
}
}
/**
* Loads a document by its slug.
* @param {string} slug - The document slug.
* @param {string} [hash] - Optional hash for scrolling to a section.
* @returns {Promise<void>}
*/
async loadDocumentBySlug(slug, hash) {
const [baseSlug] = slug.split('#');
const doc = this.indexService.findDocumentBySlug(this.indexData.documents, baseSlug);
if (!doc) {
this.domService.setError(`Document not found: ${baseSlug}`);
return;
}
if (doc.type === 'folder') {
if (doc.path) {
await this.loadDocument(doc.path, hash);
} else if (doc.items?.length > 0) {
await this.loadDocument(doc.items[0].path, hash);
} else {
this.domService.setError('This folder is empty.');
}
return;
}
await this.loadDocument(doc.path, hash);
}
/**
* Loads and renders a document from a path.
* @param {string} path - The document path.
* @param {string} [hash] - Optional hash for scrolling to a section.
* @param {boolean} [fromSearch=false] - Whether navigation came from search.
* @returns {Promise<void>}
*/
async loadDocument(path, hash, fromSearch = false) {
try {
const { content, metadata, marked, title } = await this.documentService.loadDocument(path);
this.domService.setTitle(title);
this.domService.setContent(marked.parse(content));
this.domService.updateActiveDocument(path);
const headings = document.querySelectorAll('h2, h3, h4, h5, h6');
const headingLinks = this.domService.createOutline(headings);
headings.forEach(heading => {
heading.addEventListener('click', (e) => {
if (e.target.closest('svg') || e.target.closest('.header-anchor')) return;
const id = heading.id;
if (id) {
history.pushState(null, '', `${window.location.pathname}${window.location.search}#${id}`);
this.domService.scrollToElement(heading);
heading.classList.remove('highlight');
void heading.offsetWidth;
heading.classList.add('highlight');
}
});
});
this.setupScrollObserver(headings, headingLinks);
window._currentPath = path;
if (hash) {
const element = document.getElementById(hash.slice(1));
if (element) {
const delay = fromSearch ? 300 : 100;
setTimeout(() => {
this.domService.scrollToElement(element);
element.classList.remove('highlight');
void element.offsetWidth;
element.classList.add('highlight');
}, delay);
}
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
} catch (error) {
this.domService.setError('Error loading document. Please try again.');
}
}
/**
* Sets up an intersection observer for outline highlighting.
* @param {NodeListOf<HTMLHeadingElement>} headings - The heading elements.
* @param {Map<HTMLHeadingElement, HTMLAnchorElement>} headingLinks - Map of headings to links.
* @returns {IntersectionObserver} The created observer.
* @private
*/
setupScrollObserver(headings, headingLinks) {
const observer = new IntersectionObserver(
(entries) => {
const visibleHeadings = entries
.filter(entry => entry.isIntersecting)
.sort((a, b) => a.target.offsetTop - b.target.offsetTop);
if (visibleHeadings.length) {
this.domService.elements.outline
.querySelectorAll('a')
.forEach(a => a.classList.remove('active'));
const link = headingLinks.get(visibleHeadings[0].target);
if (link) link.classList.add('active');
}
},
{
rootMargin: '-48px 0px -60% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1]
}
);
headings.forEach(heading => observer.observe(heading));
return observer;
}
}
/**
* @typedef {Object} IndexData
* @property {string} defaultPage - The default page slug.
* @property {Object} metadata - Site metadata.
* @property {Author} author - Author information.
* @property {Array<Document>} documents - The document tree.
* @property {string} [customCSS] - Optional custom CSS path.
*/
/**
* @typedef {Object} Author
* @property {string} [name] - Author name.
* @property {string} [role] - Author role.
* @property {Array<Social>} [socials] - Social media links.
*/
/**
* @typedef {Object} Social
* @property {string} url - The social media URL.
* @property {string} title - The link title.
* @property {string} icon - The Font Awesome icon class.
*/
window.originalDocTitle = document.title;
const docs = new Documentation();
// Expose to global scope for console access
window.docs = docs;

View File

@@ -1,44 +0,0 @@
/**
* A simple publish-subscribe event bus for decoupled communication between components.
* @class EventBus
*/
export class EventBus {
constructor() {
/** @type {Object.<string, Function[]>} */
this.events = {};
}
/**
* Subscribe to an event.
* @param {string} event - The event name to subscribe to.
* @param {Function} callback - The callback function to execute when the event is emitted.
* @returns {Function} An unsubscribe function that removes this listener.
*/
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
return () => this.off(event, callback);
}
/**
* Unsubscribe from an event.
* @param {string} event - The event name to unsubscribe from.
* @param {Function} callback - The callback function to remove.
*/
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
/**
* Emit an event to all subscribers.
* @param {string} event - The event name to emit.
* @param {*} data - The data to pass to all subscribers.
*/
emit(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
}

View File

@@ -1,89 +0,0 @@
/**
* Service responsible for document index operations and lookups.
* @class IndexService
*/
export class IndexService {
/**
* Finds a document by its slug in the document tree.
* @param {Array<Document>} documents - The documents to search.
* @param {string} slug - The slug to find.
* @returns {Document|null} The found document or null.
*/
findDocumentBySlug(documents, slug) {
for (const doc of documents) {
if (doc.slug === slug) return doc;
if (doc.type === 'folder') {
const found = this.findDocumentBySlug(doc.items, slug);
if (found) return found;
}
}
return null;
}
/**
* Finds a document by its title, path, or slug.
* @param {Array<Document>} documents - The documents to search.
* @param {string} title - The title to find.
* @returns {Document|null} The found document or null.
*/
findDocumentByTitle(documents, title) {
for (const doc of documents) {
if (doc.type === 'folder') {
const found = this.findDocumentByTitle(doc.items, title);
if (found) return found;
} else if (
doc.title === title ||
doc.path.endsWith(title + '.md') ||
doc.slug === title.toLowerCase().replace(/ /g, '-')
) {
return doc;
}
}
return null;
}
/**
* Finds all parent folders for a given document path.
* @param {Array<Document>} documents - The documents to search.
* @param {string} path - The path to find parents for.
* @param {Array<Document>} [parentFolders=[]] - Accumulated parent folders.
* @returns {Array<Document>} The parent folders.
*/
findParentFolders(documents, path, parentFolders = []) {
for (const doc of documents) {
if (doc.type === 'folder') {
const found = doc.items.find(item => {
if (item.path === path) return true;
if (item.type === 'folder') {
return this.findParentFolders([item], path).length > 0;
}
return false;
});
if (found) {
parentFolders.push(doc);
doc.items.forEach(item => {
if (item.type === 'folder') {
this.findParentFolders([item], path, parentFolders);
}
});
}
}
}
return parentFolders;
}
}
/**
* @typedef {Object} Document
* @property {string} title - The document title.
* @property {string} [path] - The file path.
* @property {string} slug - The URL slug.
* @property {'folder'|'file'} [type] - The document type.
* @property {Array<Document>} [items] - Child documents for folders.
* @property {Array<string>} [headers] - Document headers.
* @property {string} [icon] - Custom icon class.
* @property {boolean} [defaultOpen] - Whether folder is open by default.
* @property {string} [showfolderpage] - Whether to show folder page.
* @property {Object} [metadata] - Additional metadata.
*/

View File

@@ -1,44 +0,0 @@
/**
* Service responsible for handling navigation events and browser history.
* @class NavigationService
*/
export class NavigationService {
/**
* Creates a new NavigationService instance.
* @param {EventBus} eventBus - The event bus for communication.
* @param {DocumentService} documentService - The document service.
*/
constructor(eventBus, documentService) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {DocumentService} */
this.documentService = documentService;
this.setupEventListeners();
}
/**
* Sets up browser history and internal link event listeners.
* @private
*/
setupEventListeners() {
window.addEventListener('popstate', async () => {
const search = window.location.search;
const hash = window.location.hash;
const slug = (search === '' || search === '?') ?
window._indexData.defaultPage :
search.replace(/^\?/, '').split('#')[0];
this.eventBus.emit('navigation:requested', { slug, hash });
});
document.addEventListener('click', async (e) => {
const target = e.target.closest('a[data-internal="true"]');
if (target) {
e.preventDefault();
const slug = target.href.split('?').pop();
history.pushState(null, '', `?${slug}`);
this.eventBus.emit('navigation:requested', { slug });
}
});
}
}

View File

@@ -1,200 +0,0 @@
/**
* Service responsible for building and querying the search index.
* @class SearchService
*/
export class SearchService {
/**
* Creates a new SearchService instance.
* @param {EventBus} eventBus - The event bus for communication.
* @param {IndexService} indexService - The index service for document lookups.
*/
constructor(eventBus, indexService) {
/** @type {EventBus} */
this.eventBus = eventBus;
/** @type {IndexService} */
this.indexService = indexService;
/** @type {Array<SearchIndexItem>} */
this.searchIndex = [];
/** @type {Map<string, Array<SearchResult>>} */
this.searchCache = new Map();
/** @type {number} */
this.cacheMaxSize = 50;
}
/**
* Builds the search index from a list of documents.
* @param {Array<Document>} documents - The documents to index.
*/
buildSearchIndex(documents) {
this.searchIndex = [];
this.searchCache.clear();
this.processDocuments(documents);
}
/**
* Recursively processes documents and adds them to the search index.
* @param {Array<Document>} documents - The documents to process.
* @param {string} [parentPath=''] - The parent path for breadcrumb display.
* @private
*/
processDocuments(documents, parentPath = '') {
documents.forEach(doc => {
if (doc.type === 'folder') {
this.processFolderDocument(doc, parentPath);
} else {
this.processFileDocument(doc, parentPath);
}
});
}
/**
* Processes a folder document and its children.
* @param {Document} doc - The folder document to process.
* @param {string} parentPath - The parent path for breadcrumb display.
* @private
*/
processFolderDocument(doc, parentPath) {
const currentPath = parentPath ? `${parentPath} / ${doc.title}` : doc.title;
if (doc.path) {
if (doc.showfolderpage !== 'false') {
this.searchIndex.push({
title: doc.title,
path: doc.path,
slug: doc.slug,
location: currentPath,
type: 'folder'
});
}
if (doc.headers) {
this.addHeadersToIndex(doc, currentPath, doc.title);
}
}
if (doc.items) {
this.processDocuments(doc.items, currentPath);
}
}
/**
* Processes a file document.
* @param {Document} doc - The file document to process.
* @param {string} parentPath - The parent path for breadcrumb display.
* @private
*/
processFileDocument(doc, parentPath) {
this.searchIndex.push({
title: doc.title,
path: doc.path,
slug: doc.slug,
location: parentPath,
type: 'file'
});
if (doc.headers) {
this.addHeadersToIndex(doc, parentPath, doc.title);
}
}
/**
* Adds document headers to the search index.
* @param {Document} doc - The document containing headers.
* @param {string} location - The location path for display.
* @param {string} docTitle - The document title.
* @private
*/
addHeadersToIndex(doc, location, docTitle) {
doc.headers.forEach(header => {
this.searchIndex.push({
title: header,
path: doc.path,
slug: `${doc.slug}#${this.slugifyHeader(header)}`,
location: `${location} / ${docTitle}`,
type: 'header'
});
});
}
/**
* Converts a header text to a URL-friendly slug.
* @param {string} header - The header text to slugify.
* @returns {string} The slugified header.
* @private
*/
slugifyHeader(header) {
return header.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
}
/**
* Searches the index for documents matching the query.
* @param {string} query - The search query.
* @returns {Array<SearchResult>} The search results sorted by relevance.
*/
search(query) {
if (!query) return [];
query = query.toLowerCase();
if (this.searchCache.has(query)) {
return this.searchCache.get(query);
}
const results = this.searchIndex
.map(item => this.scoreItem(item, query))
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10);
if (this.searchCache.size >= this.cacheMaxSize) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
this.searchCache.set(query, results);
return results;
}
/**
* Calculates a relevance score for a search index item.
* @param {SearchIndexItem} item - The item to score.
* @param {string} query - The search query (lowercase).
* @returns {SearchResult} The item with its score.
* @private
*/
scoreItem(item, query) {
const titleLower = item.title.toLowerCase();
let score = 0;
if (titleLower === query) score = 100;
else if (titleLower.startsWith(query)) score = 80;
else if (titleLower.includes(query)) score = 60;
else if (item.path.toLowerCase().includes(query)) score = 40;
else if (item.location.toLowerCase().includes(query)) score = 20;
if (item.type === 'header') score += 5;
return { ...item, score };
}
/**
* Clears the search result cache.
*/
clearCache() {
this.searchCache.clear();
}
}
/**
* @typedef {Object} SearchIndexItem
* @property {string} title - The item title.
* @property {string} path - The file path.
* @property {string} slug - The URL slug.
* @property {string} location - The breadcrumb location.
* @property {'folder'|'file'|'header'} type - The item type.
*/
/**
* @typedef {SearchIndexItem & {score: number}} SearchResult
*/

3432
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,54 @@
{
"name": "docs-viewer",
"version": "1.0.0",
"description": "Documentation viewer application",
"version": "2.0.0",
"description": "Static documentation viewer on slatehtml + slatehtml-ui",
"type": "module",
"main": "./src/index.js",
"module": "./src/index.js",
"exports": {
".": "./src/index.js",
"./prose.css": "./src/prose.css",
"./build-docs": "./build-docs.cjs",
"./package.json": "./package.json"
},
"files": [
"src",
"build-docs.cjs",
"README.md",
"LICENSE"
],
"sideEffects": [
"./src/index.js",
"./src/prose.css",
"**/*.umc",
"**/*.css",
"**/*.js"
],
"scripts": {
"start": "live-server --port=8080 --no-browser",
"build": "node build-docs.js"
"dev": "vite",
"build": "node build-docs.cjs && vite build",
"build:index": "node build-docs.cjs",
"preview": "vite preview",
"start": "vite"
},
"dependencies": {
"highlight.js": "^11.11.1",
"marked": "^15.0.12",
"slatehtml": "file:../slatehtml",
"slatehtml-ui": "file:../slatehtml/packages/slatehtml-ui"
},
"devDependencies": {
"live-server": "^1.2.2"
"vite": "^6.3.5"
},
"keywords": [
"docs",
"markdown",
"documentation",
"slatehtml"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/litruv/Docs-Viewer.git"
}
}

425
src/app.js Normal file
View File

@@ -0,0 +1,425 @@
import { EventBus } from "./services/event-bus.js";
import { IndexService } from "./services/index-service.js";
import { SearchService } from "./services/search-service.js";
import { DocumentService } from "./services/document-service.js";
import { NavigationService } from "./services/navigation-service.js";
import { addContent } from "slatehtml/umc";
/** Replace children via widget `set` when available, else `addContent`. */
function fill(host, specs) {
if (!host) return;
if (typeof host.set === "function") {
host.set(...(specs || []));
return;
}
host.replaceChildren();
if (specs?.length) addContent(host, specs);
}
/**
* Orchestrates index load, navigation, search, and markdown rendering
* against a stamped <slate-docs-viewer> host.
*/
export class DocsApp {
/**
* @param {HTMLElement} host slate-docs-viewer element
* @param {{ indexUrl?: string }} [options]
*/
constructor(host, options = {}) {
this.host = host;
this.indexUrl = options.indexUrl || host.getAttribute("index") || "./index.json";
this.eventBus = new EventBus();
this.indexService = new IndexService();
this.searchService = new SearchService(this.eventBus, this.indexService);
this.documentService = new DocumentService(
this.eventBus,
this.indexService,
() => this.indexData
);
this.navigationService = new NavigationService(this.eventBus, () =>
this.indexData?.defaultPage || "home"
);
this.indexData = null;
this._outlineObserver = null;
this._searchTimer = null;
this.$ = {
nav: () => host.querySelector("[data-docs-nav]"),
outline: () => host.querySelector("[data-docs-outline]"),
markdown: () => host.querySelector("[data-docs-markdown]"),
error: () => host.querySelector("[data-docs-error]"),
progress: () => host.querySelector("[data-docs-progress]"),
articleScroll: () => host.querySelector("[data-docs-article-scroll]"),
siteName: () => host.querySelector("[data-docs-site-name]"),
authorRole: () => host.querySelector("[data-docs-author-role]"),
socials: () => host.querySelector("[data-docs-socials]"),
search: () => host.querySelector("[data-docs-search]"),
searchPanel: () => host.querySelector("[data-docs-search-panel]"),
};
}
async start() {
this.bindChrome();
this.eventBus.on("navigation:requested", ({ slug, hash }) => {
this.loadDocumentBySlug(slug, hash);
});
try {
const res = await fetch(this.indexUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.indexData = await res.json();
window._indexData = this.indexData;
this.applyIndexChrome(this.indexData);
this.searchService.buildSearchIndex(this.indexData.documents || []);
const nav = this.$.nav();
nav?.setDocuments?.(this.indexData.documents || []);
const { slug, hash } = this.navigationService.readLocation();
await this.loadDocumentBySlug(slug, hash);
} catch (err) {
this.showError(`Failed to load documentation index: ${err.message}`);
}
}
bindChrome() {
const nav = this.$.nav();
nav?.addEventListener("navigate", (e) => {
const slug = e.detail?.slug;
if (!slug) return;
this.navigationService.navigate(slug);
});
const outline = this.$.outline();
outline?.addEventListener("navigate", (e) => {
const id = e.detail?.id;
if (!id) return;
this.scrollToId(id, true);
});
const md = this.$.markdown();
md?.addEventListener("rendered", (e) => {
const headings = e.detail?.headings || [];
outline?.setHeadings?.(headings);
this.setupOutlineObserver(headings);
});
md?.addEventListener("headingclicked", (e) => {
const id = e.detail?.id;
if (!id) return;
this.scrollToId(id, true);
});
const search = this.$.search();
const onSearch = () => {
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => {
const value =
search?.getAttribute?.("text") ||
search?.textContent ||
"";
this.renderSearch(value);
}, 60);
};
search?.addEventListener("textchanged", onSearch);
search?.addEventListener("input", onSearch);
search?.addEventListener("keyup", onSearch);
document.addEventListener("keydown", (e) => {
const typing =
["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName) ||
document.activeElement?.isContentEditable ||
document.activeElement?.tagName === "EDITABLETEXT" ||
document.activeElement?.localName === "editabletext" ||
document.activeElement?.localName === "umc-editabletext";
if ((e.key === "s" || e.key === "S") && (e.altKey || (!e.ctrlKey && !e.metaKey && !typing))) {
if (!e.altKey && typing) return;
e.preventDefault();
this.focusSearch();
}
if (e.key === "Escape") this.hideSearchPanel();
});
document.addEventListener("click", (e) => {
const panel = this.$.searchPanel();
const searchField = this.$.search();
const block = this.host.querySelector(".docs-viewer-search-block");
if (!panel || panel.hasAttribute("hidden")) return;
if (block?.contains(e.target)) return;
if (panel.contains(e.target) || searchField?.contains(e.target)) return;
this.hideSearchPanel();
});
}
applyIndexChrome(data) {
const site = data.metadata?.site_name || data.metadata?.title || "Docs";
const siteEl = this.$.siteName();
if (siteEl) siteEl.setAttribute("text", site);
document.title = site;
const logo = this.host.querySelector("[data-docs-logo]");
if (logo) {
logo.setAttribute("alt", site);
const logoUrl = (data.metadata?.logo || this.host.getAttribute("logo") || "./img/logo.png").trim();
if (logoUrl) {
logo.setAttribute("src", logoUrl);
logo.removeAttribute("hidden");
siteEl?.setAttribute("hidden", "");
}
}
const role = this.$.authorRole();
if (role) {
role.setAttribute("text", data.author?.role || "");
role.toggleAttribute("hidden", !data.author?.role);
}
const socials = this.$.socials();
if (socials) {
const entries = (data.author?.socials || []).map((s) => {
const icon = faClassToIconName(s.icon);
const label = shortSocialLabel(s.title || s.url || "link");
// value carries the URL so selection can open it
return `${encodeURIComponent(s.url)}|${label}|${icon}`;
});
socials.setAttribute("options", entries.join(","));
socials.setAttribute("labels", "hide");
if (!socials._docsSocialBound) {
socials._docsSocialBound = true;
socials.addEventListener("selectionchanged", (e) => {
const raw = e.detail?.value || "";
let url = raw;
try {
url = decodeURIComponent(raw);
} catch {
/* keep raw */
}
if (/^https?:\/\//i.test(url)) {
window.open(url, "_blank", "noopener,noreferrer");
}
// don't leave a "selected" social highlighted
socials.removeAttribute("selected");
});
}
}
if (data.customCSS) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = data.customCSS;
document.head.appendChild(link);
}
}
async loadDocumentBySlug(slug, hash = "") {
const base = String(slug || "").split("#")[0];
const doc = this.indexService.findDocumentBySlug(this.indexData?.documents || [], base);
if (!doc) {
this.showError(`Document not found: ${base}`);
return;
}
if (doc.type === "folder") {
if (doc.path) await this.loadDocument(doc.path, hash, doc.slug);
else if (doc.items?.[0]?.path) {
await this.loadDocument(doc.items[0].path, hash, doc.items[0].slug);
} else this.showError("This folder is empty.");
return;
}
await this.loadDocument(doc.path, hash, doc.slug);
}
async loadDocument(path, hash = "", slug = "") {
this.setProgress(true);
this.clearError();
try {
const { content, title } = await this.documentService.loadDocument(path);
const md = this.$.markdown();
if (md) {
md.__mdContent = null;
md.setAttribute("content", content);
}
document.title = `${title} · ${this.indexData?.metadata?.site_name || "Docs"}`;
const fromPath = findSlugByPath(this.indexData?.documents || [], path);
this.$.nav()?.setAttribute("selected", fromPath || slug || "");
const scroll = this.$.articleScroll();
if (hash) {
const id = hash.replace(/^#/, "");
requestAnimationFrame(() => setTimeout(() => this.scrollToId(id, false), 40));
} else if (scroll) {
scroll.scrollTop = 0;
}
} catch (err) {
this.showError(err.message || "Error loading document.");
} finally {
this.setProgress(false);
}
}
setupOutlineObserver(headings) {
this._outlineObserver?.disconnect();
const root = this.$.articleScroll();
if (!root || !headings?.length) return;
const map = new Map(headings.map((h) => [h.el, h.id]));
this._outlineObserver = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.target.offsetTop - b.target.offsetTop);
if (!visible.length) return;
const id = map.get(visible[0].target);
if (id) this.$.outline()?.setAttribute("active", id);
},
{ root, rootMargin: "-48px 0px -55% 0px", threshold: [0, 0.25, 1] }
);
for (const h of headings) {
if (h.el) this._outlineObserver.observe(h.el);
}
}
scrollToId(id, pushHash) {
const el = this.host.querySelector(`#${CSS.escape(id)}`);
const scroll = this.$.articleScroll();
if (!el || !scroll) return;
// Open any collapsed ancestors so the heading is visible (docs fold).
let node = el.parentElement;
while (node && node !== this.host) {
if (node.localName === "slate-collapse" && !node.hasAttribute("open")) {
if (typeof node.toggle === "function") node.toggle(true);
else node.setAttribute("open", "");
}
node = node.parentElement;
}
const top = el.offsetTop - 12;
scroll.scrollTo({ top, behavior: "smooth" });
if (pushHash) {
const { slug } = this.navigationService.readLocation();
history.pushState(null, "", `?${slug}#${id}`);
}
el.classList.remove("md-heading-flash");
void el.offsetWidth;
el.classList.add("md-heading-flash");
}
renderSearch(query) {
const panel = this.$.searchPanel();
if (!panel) return;
const q = String(query || "").trim();
if (!q) {
this.hideSearchPanel();
return;
}
const results = this.searchService.search(q);
panel.removeAttribute("hidden");
const specs = results.length
? results.map((r) => ({
tag: "border",
class: "docs-search-hit",
role: "option",
tabindex: "0",
"data-search-slug": r.slug,
children: [
{ tag: "textblock", class: "docs-search-hit-title", text: r.title },
{
tag: "textblock",
class: "docs-search-hit-meta",
text: r.location || r.type,
},
],
}))
: [
{
tag: "textblock",
class: "docs-search-hit-meta",
text: "No results",
},
];
fill(panel, specs);
panel.onclick = (e) => {
const hit = e.target.closest?.("[data-search-slug]");
if (!hit) return;
const slug = hit.getAttribute("data-search-slug");
this.hideSearchPanel();
const search = this.$.search();
if (search) search.setAttribute("text", "");
this.navigationService.navigate(slug);
};
}
hideSearchPanel() {
this.$.searchPanel()?.setAttribute("hidden", "");
}
focusSearch() {
const field = this.$.search();
field?.focus?.();
const sidebar = this.host.querySelector("#docs-viewer-sidebar");
if (sidebar?.getAttribute("layout") === "drawer" && !sidebar.hasAttribute("open")) {
sidebar.setAttribute("open", "");
}
}
setProgress(on) {
this.$.progress()?.toggleAttribute("hidden", !on);
}
showError(message) {
const el = this.$.error();
if (!el) return;
el.removeAttribute("hidden");
el.setAttribute("text", message);
const md = this.$.markdown();
if (md) {
md.__mdContent = null;
md.setAttribute("content", "");
}
}
clearError() {
const el = this.$.error();
if (!el) return;
el.setAttribute("hidden", "");
el.removeAttribute("text");
}
}
function findSlugByPath(documents, path) {
for (const doc of documents || []) {
if (doc.path === path) return doc.slug;
if (doc.type === "folder" && doc.items) {
const found = findSlugByPath(doc.items, path);
if (found) return found;
}
}
return "";
}
/** Map Font Awesome class strings to slate-icon names (FA provider). */
export function faClassToIconName(iconClass) {
const parts = String(iconClass || "")
.split(/\s+/)
.filter(Boolean);
const namePart = parts.find(
(p) =>
p.startsWith("fa-") &&
!["fa-solid", "fa-brands", "fa-regular", "fa-sharp"].includes(p) &&
p !== "fa"
);
const raw = (namePart || "link").replace(/^fa-/, "");
if (parts.some((p) => p.includes("brands") || p === "fab")) return `fab:${raw}`;
if (parts.some((p) => p.includes("regular") || p === "far")) return `far:${raw}`;
if (parts.some((p) => p.includes("solid") || p === "fas")) return `fas:${raw}`;
return raw;
}
function shortSocialLabel(title) {
const t = String(title || "").trim();
if (/github/i.test(t)) return "GitHub";
if (/youtube/i.test(t)) return "YouTube";
if (/steam/i.test(t)) return "Steam";
if (/discord/i.test(t)) return "Discord";
if (/bluesky|bsky/i.test(t)) return "Bluesky";
const head = t.split(/\s[-–—]\s/)[0]?.trim() || t;
return head.slice(0, 12) || "Link";
}

73
src/index.js Normal file
View File

@@ -0,0 +1,73 @@
/**
* docs-viewer — documentation viewer on slatehtml + slatehtml-ui.
*
* import "slatehtml";
* import { mountDocs } from "docs-viewer";
* import "docs-viewer/prose.css";
*
* await mountDocs(document.querySelector("slate-docs-viewer"));
*/
import { deferCustomElementDefines } from "slatehtml/umc";
import { DocsApp, faClassToIconName } from "./app.js";
import { markdownToSpecs, collectHeadings } from "./markdown/render.js";
import { EventBus } from "./services/event-bus.js";
import { IndexService } from "./services/index-service.js";
import { SearchService } from "./services/search-service.js";
import { DocumentService } from "./services/document-service.js";
import { NavigationService } from "./services/navigation-service.js";
const modules = import.meta.glob(["./widgets/*.umc"]);
await deferCustomElementDefines(async () => {
await Promise.all(
Object.entries(modules).map(async ([path, load]) => {
try {
await load();
} catch (err) {
console.error(`[slatehtml-docs] failed to load ${path}`, err);
}
})
);
});
/**
* Mount / start the docs app on a <slate-docs-viewer> (or create one).
* @param {ParentNode|string|null} target
* @param {{ indexUrl?: string, create?: boolean }} [options]
* @returns {Promise<DocsApp>}
*/
export async function mountDocs(target = "slate-docs-viewer", options = {}) {
let host =
typeof target === "string" ? document.querySelector(target) : target;
if (!host && options.create !== false) {
host = document.createElement("slate-docs-viewer");
if (options.indexUrl) host.setAttribute("index", options.indexUrl);
document.body.appendChild(host);
}
if (!host) throw new Error("mountDocs: no slate-docs-viewer host found");
// Wait a frame so UMC Construct/stamp finishes.
await Promise.resolve();
await new Promise((r) => requestAnimationFrame(() => r()));
const app = new DocsApp(host, {
indexUrl: options.indexUrl || host.getAttribute("index") || "./index.json",
});
await app.start();
host.__docsApp = app;
return app;
}
export {
DocsApp,
faClassToIconName,
markdownToSpecs,
collectHeadings,
EventBus,
IndexService,
SearchService,
DocumentService,
NavigationService,
};

24
src/markdown/decorate.js Normal file
View File

@@ -0,0 +1,24 @@
/**
* Light post-pass for marked HTML: wiki `?slug` links + lazy images.
* Content is trusted site markdown — no tag stripping.
*/
export function decorateHtml(html) {
if (!html) return "";
if (typeof document === "undefined") return String(html);
const tpl = document.createElement("template");
tpl.innerHTML = String(html);
for (const a of tpl.content.querySelectorAll("a[href]")) {
const href = a.getAttribute("href") || "";
if (href.startsWith("?")) a.setAttribute("data-internal", "true");
else if (href.startsWith("http")) {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
}
}
for (const img of tpl.content.querySelectorAll("img:not([loading])")) {
img.setAttribute("loading", "lazy");
}
return tpl.innerHTML;
}

View File

@@ -0,0 +1,60 @@
/**
* Frontmatter, wiki links, and wiki image embeds (Docs-Viewer compatible).
*/
export function extractMetadata(content) {
const lines = String(content || "").trim().split("\n");
let metadata = {};
let contentStart = 0;
if (lines[0]?.trim() === "---") {
const endMetadata = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
if (endMetadata !== -1) {
const entries = lines
.slice(1, endMetadata)
.map((line) => line.match(/^([\w-]+):\s*(.*)$/))
.filter(Boolean)
.map(([, key, value]) => {
if (key === "defaultOpen") {
return [key, value.trim().toLowerCase() === "true"];
}
if (key === "sort") return [key, parseInt(value.trim(), 10)];
return [key, value.trim()];
});
metadata = Object.fromEntries(entries);
contentStart = endMetadata + 1;
}
}
return {
metadata,
content: lines.slice(contentStart).join("\n").trim(),
};
}
export function ensureTitle(content, title) {
const stripped = String(content || "")
.replace(/^#\s+.*$/m, "")
.trim();
return `# ${title}\n\n${stripped}`;
}
export function processWikiLinks(content, indexService, documents) {
return String(content || "").replace(/\[\[(.*?)\]\]/g, (match, linkText) => {
const [targetTitle, displayText] = linkText.split("|").map((s) => s.trim());
if (/\.(png|jpg|jpeg|gif|webp|mp4|webm)$/i.test(targetTitle)) return match;
const doc = indexService.findDocumentByTitle(documents, targetTitle);
if (!doc) return match;
return `[${displayText || doc.title}](?${doc.slug})`;
});
}
export function processImages(content, _basePath) {
return String(content || "").replace(/!\[\[(.*?)\]\]/g, (match, filename) => {
const mediaPath = `./docs/images/${filename}`;
if (filename.toLowerCase().endsWith(".mp4") || filename.toLowerCase().endsWith(".webm")) {
return `\n\n<video src="${mediaPath}" controls playsinline></video>\n\n`;
}
return `\n\n![${filename}](${mediaPath})\n\n`;
});
}

263
src/markdown/render.js Normal file
View File

@@ -0,0 +1,263 @@
import { marked } from "marked";
import hljs from "highlight.js/lib/core";
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import css from "highlight.js/lib/languages/css";
import json from "highlight.js/lib/languages/json";
import bash from "highlight.js/lib/languages/bash";
import cpp from "highlight.js/lib/languages/cpp";
import c from "highlight.js/lib/languages/c";
import python from "highlight.js/lib/languages/python";
import markdown from "highlight.js/lib/languages/markdown";
import { decorateHtml } from "./decorate.js";
import { slugifyHeader } from "./slug.js";
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("js", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("ts", typescript);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("html", xml);
hljs.registerLanguage("css", css);
hljs.registerLanguage("json", json);
hljs.registerLanguage("bash", bash);
hljs.registerLanguage("sh", bash);
hljs.registerLanguage("shell", bash);
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("c", c);
hljs.registerLanguage("python", python);
hljs.registerLanguage("py", python);
hljs.registerLanguage("markdown", markdown);
hljs.registerLanguage("md", markdown);
const HEADING_KIND = {
1: "title",
2: "section",
3: "subtitle",
4: "body",
5: "body",
6: "hint",
};
marked.setOptions({ breaks: true, gfm: true });
/**
* Convert markdown source into slate `create()` specs (blocks as widgets,
* inline as HTML in slate-rich-text). Headings depth≥2 wrap following
* content in <slate-collapse kind="section"> (docs-viewer fold behavior).
*/
export function markdownToSpecs(source) {
const tokens = marked.lexer(String(source || ""));
return tokensToSpecs(tokens);
}
function tokensToSpecs(tokens) {
const list = tokens || [];
const specs = [];
let i = 0;
while (i < list.length) {
const token = list[i];
if (token.type === "heading" && token.depth >= 2) {
const level = token.depth;
const bodyTokens = [];
i += 1;
while (i < list.length) {
const next = list[i];
if (next.type === "heading" && next.depth <= level) break;
bodyTokens.push(next);
i += 1;
}
const bodySpecs = tokensToSpecsFlat(bodyTokens);
specs.push(collapseSectionSpec(token, bodySpecs));
continue;
}
const spec = tokenToSpec(token);
if (spec) {
if (Array.isArray(spec)) specs.push(...spec);
else specs.push(spec);
}
i += 1;
}
return specs;
}
/** Flat map (no further heading→collapse grouping). */
function tokensToSpecsFlat(tokens) {
const specs = [];
for (const token of tokens || []) {
const spec = tokenToSpec(token);
if (!spec) continue;
if (Array.isArray(spec)) specs.push(...spec);
else specs.push(spec);
}
return specs;
}
function headingSpec(token) {
const text = inlineText(token);
const id = slugifyHeader(text);
return {
tag: "slate-rich-text",
kind: `h${token.depth}`,
class: `md-h md-h${token.depth}`,
id,
html: decorateHtml(inlineHtml(token)),
"data-heading": String(token.depth),
"data-heading-text": text,
};
}
function collapseSectionSpec(headingToken, bodySpecs) {
const text = inlineText(headingToken);
const id = slugifyHeader(text);
return {
tag: "slate-collapse",
class: "md-collapse",
kind: "section",
open: "",
title: text,
id,
"data-heading": String(headingToken.depth),
"data-heading-text": text,
children: bodySpecs,
};
}
function tokenToSpec(token) {
switch (token.type) {
case "space":
return null;
case "heading":
return headingSpec(token);
case "paragraph":
return {
tag: "slate-rich-text",
kind: "paragraph",
class: "md-p",
html: decorateHtml(inlineHtml(token)),
};
case "text":
return {
tag: "slate-rich-text",
kind: "paragraph",
class: "md-p",
html: decorateHtml(inlineHtml(token)),
};
case "blockquote":
return {
tag: "border",
kind: "well",
class: "md-blockquote",
padding: "10 14",
children: tokensToSpecsFlat(token.tokens || []),
};
case "hr":
return { tag: "slate-divider", class: "md-hr" };
case "code":
return codeBlockSpec(token.text, token.lang);
case "list":
return listSpec(token);
case "table":
return tableSpec(token);
case "html":
return {
tag: "slate-rich-text",
kind: "html",
class: "md-html",
html: decorateHtml(token.text || token.raw || ""),
};
default:
if (token.raw) {
return {
tag: "slate-rich-text",
kind: "paragraph",
class: "md-p",
html: decorateHtml(marked.parse(token.raw)),
};
}
return null;
}
}
function listSpec(token) {
const tag = token.ordered ? "ol" : "ul";
// Keep list semantics in HTML for accessibility / nesting, styled via prose.
const html = decorateHtml(marked.Parser.parse([token]));
return {
tag: "slate-rich-text",
kind: "list",
class: `md-list md-list-${tag}`,
html,
};
}
function tableSpec(token) {
const html = decorateHtml(marked.Parser.parse([token]));
return {
tag: "slate-rich-text",
kind: "table",
class: "md-table-wrap",
html,
};
}
function codeBlockSpec(code, lang) {
let highlighted;
let language = lang || "";
try {
if (language && hljs.getLanguage(language)) {
highlighted = hljs.highlight(code, { language }).value;
} else {
const auto = hljs.highlightAuto(code);
highlighted = auto.value;
language = auto.language || "";
}
} catch {
highlighted = escapeHtml(code);
}
const cls = `hljs${language ? ` language-${language}` : ""}`;
return {
tag: "border",
class: "md-code-block",
padding: "16",
children: [
{
tag: "slate-rich-text",
kind: "code",
class: "md-code",
html: decorateHtml(`<pre class="${cls}"><code>${highlighted}</code></pre>`),
},
],
};
}
function inlineHtml(token) {
if (token.tokens) return marked.Parser.parseInline(token.tokens);
if (token.text) return escapeHtml(token.text);
return "";
}
function inlineText(token) {
if (!token.tokens) return String(token.text || "").trim();
return token.tokens.map((t) => t.text || t.raw || "").join("").trim();
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Collect outline entries from rendered article host. */
export function collectHeadings(root) {
if (!root) return [];
return [...root.querySelectorAll("[data-heading]")].map((el) => ({
id: el.id || "",
level: Number(el.getAttribute("data-heading") || "2"),
text: el.getAttribute("data-heading-text") || el.textContent || "",
el,
}));
}

7
src/markdown/slug.js Normal file
View File

@@ -0,0 +1,7 @@
/** Header text → URL fragment id (Docs-Viewer compatible). */
export function slugifyHeader(header) {
return String(header || "")
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
}

89
src/prose.css Normal file
View File

@@ -0,0 +1,89 @@
/* Article / highlight extras for docs-viewer (import alongside the viewer). */
.md-collapse {
margin: 0;
}
/* Depth rhythm for nested heading collapses (OG markdown-content h2/h3). */
.md-collapse[data-heading="3"] .slate-collapse-header {
margin: 2rem 0 0.75rem;
}
.md-collapse[data-heading="3"] .slate-collapse-title-text {
font-size: 1.5rem;
font-weight: 600;
}
.md-collapse[data-heading="4"] .slate-collapse-header,
.md-collapse[data-heading="5"] .slate-collapse-header,
.md-collapse[data-heading="6"] .slate-collapse-header {
margin: 1.5rem 0 0.5rem;
}
.md-collapse[data-heading="4"] .slate-collapse-title-text,
.md-collapse[data-heading="5"] .slate-collapse-title-text,
.md-collapse[data-heading="6"] .slate-collapse-title-text {
font-size: 1.15rem;
font-weight: 600;
}
.md-heading-flash {
animation: docs-heading-flash 1.1s ease;
}
@keyframes docs-heading-flash {
0% {
background: color-mix(in srgb, var(--accent, #2196f3) 35%, transparent);
}
100% {
background: transparent;
}
}
/* highlight.js-ish tokens (minimal, theme via currentColor) */
.hljs {
color: inherit;
background: transparent;
}
.hljs-comment,
.hljs-quote {
opacity: 0.55;
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-built_in,
.hljs-name,
.hljs-tag {
color: var(--docs-syntax-keyword, #c6a0f6);
}
.hljs-string,
.hljs-title,
.hljs-section,
.hljs-attribute,
.hljs-literal,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-addition {
color: var(--docs-syntax-string, #a6da95);
}
.hljs-deletion,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-meta {
color: var(--docs-syntax-meta, #ed8796);
}
.hljs-doctag,
.hljs-number,
.hljs-regexp {
color: var(--docs-syntax-number, #f5a97f);
}
.hljs-symbol,
.hljs-variable,
.hljs-template-variable,
.hljs-link,
.hljs-selector-id,
.hljs-selector-class {
color: var(--docs-syntax-symbol, #8aadf4);
}

View File

@@ -0,0 +1,77 @@
import { marked } from "marked";
import { processWikiLinks, processImages, extractMetadata, ensureTitle } from "../markdown/preprocess.js";
/**
* Load markdown docs, preprocess wiki links / images, cache results.
* Rendering to slate specs is handled by markdown/render.js.
*/
export class DocumentService {
constructor(eventBus, indexService, getIndexData) {
this.eventBus = eventBus;
this.indexService = indexService;
this.getIndexData = getIndexData;
this.documentCache = new Map();
this.cacheMaxSize = 20;
marked.setOptions({ breaks: true, gfm: true });
}
extractMetadata(content) {
return extractMetadata(content);
}
findDocInIndex(path) {
const data = this.getIndexData?.() || window._indexData;
const docs = data?.documents || [];
let doc = docs.find((d) => d.path === path);
if (doc) return doc;
for (const d of docs) {
if (d.type === "folder" && d.items) {
doc = d.items.find((item) => item.path === path);
if (doc) return doc;
// one more nesting level common in unreal-docs
for (const child of d.items) {
if (child.type === "folder" && child.items) {
doc = child.items.find((item) => item.path === path);
if (doc) return doc;
}
}
}
}
return null;
}
async loadDocument(path) {
if (this.documentCache.has(path)) return this.documentCache.get(path);
const response = await fetch(path);
if (!response.ok) throw new Error(`Failed to load ${path}`);
const rawContent = await response.text();
const { metadata, content } = extractMetadata(rawContent);
const data = this.getIndexData?.() || window._indexData;
const indexDoc = this.findDocInIndex(path);
const title =
metadata.title || indexDoc?.title || path.split("/").pop().replace(/\.md$/, "");
let processed = processWikiLinks(content, this.indexService, data?.documents || []);
const basePath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ".";
processed = processImages(processed, basePath);
processed = ensureTitle(processed, title);
const result = { content: processed, metadata, title, path };
if (this.documentCache.size >= this.cacheMaxSize) {
const firstKey = this.documentCache.keys().next().value;
this.documentCache.delete(firstKey);
}
this.documentCache.set(path, result);
return result;
}
clearCache() {
this.documentCache.clear();
}
invalidateDocument(path) {
this.documentCache.delete(path);
}
}

22
src/services/event-bus.js Normal file
View File

@@ -0,0 +1,22 @@
/** Simple pub-sub bus for docs viewer services. */
export class EventBus {
constructor() {
this.events = {};
}
on(event, callback) {
if (!this.events[event]) this.events[event] = [];
this.events[event].push(callback);
return () => this.off(event, callback);
}
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter((cb) => cb !== callback);
}
emit(event, data) {
if (!this.events[event]) return;
for (const callback of this.events[event]) callback(data);
}
}

View File

@@ -0,0 +1,76 @@
/** Document tree lookups against index.json. */
export class IndexService {
findDocumentBySlug(documents, slug) {
for (const doc of documents || []) {
if (doc.slug === slug) return doc;
if (doc.type === "folder" && doc.items) {
const found = this.findDocumentBySlug(doc.items, slug);
if (found) return found;
}
}
return null;
}
findDocumentByTitle(documents, title) {
const want = String(title || "").trim();
if (!want) return null;
const wantLower = want.toLowerCase();
const wantSlug = wantLower.replace(/\s+/g, "-");
for (const doc of documents || []) {
if (doc.type === "folder" && doc.items) {
const found = this.findDocumentByTitle(doc.items, title);
if (found) return found;
continue;
}
if (docMatchesTitle(doc, want, wantLower, wantSlug)) return doc;
}
return null;
}
findParentFolders(documents, path, parentFolders = []) {
for (const doc of documents || []) {
if (doc.type !== "folder" || !doc.items) continue;
const found = doc.items.find((item) => {
if (item.path === path) return true;
if (item.type === "folder") {
return this.findParentFolders([item], path).length > 0;
}
return false;
});
if (found) {
parentFolders.push(doc);
for (const item of doc.items) {
if (item.type === "folder") {
this.findParentFolders([item], path, parentFolders);
}
}
}
}
return parentFolders;
}
/** Flatten file docs (and folder pages) in sidebar order. */
flattenDocuments(documents, out = []) {
for (const doc of documents || []) {
if (doc.type === "folder") {
if (doc.path && doc.showfolderpage !== "false") out.push(doc);
if (doc.items) this.flattenDocuments(doc.items, out);
} else {
out.push(doc);
}
}
return out;
}
}
function docMatchesTitle(doc, want, wantLower, wantSlug) {
if (!doc) return false;
if (doc.title === want) return true;
if (String(doc.title || "").toLowerCase() === wantLower) return true;
if (doc.path && doc.path.toLowerCase().endsWith(`/${wantSlug}.md`)) return true;
if (doc.path && doc.path.toLowerCase().endsWith(`${wantSlug}.md`)) return true;
if (doc.slug === wantSlug) return true;
if (doc.slug && doc.slug.toLowerCase().endsWith(`/${wantSlug}`)) return true;
return false;
}

View File

@@ -0,0 +1,54 @@
/** Browser history + internal `?slug` link clicks. */
export class NavigationService {
constructor(eventBus, getDefaultPage) {
this.eventBus = eventBus;
this.getDefaultPage = getDefaultPage;
this.setupEventListeners();
}
setupEventListeners() {
window.addEventListener("popstate", () => {
const { slug, hash } = this.readLocation();
this.eventBus.emit("navigation:requested", { slug, hash });
});
document.addEventListener("click", (e) => {
const target = e.target.closest?.("a[data-internal='true']");
if (!target) return;
e.preventDefault();
const raw = target.getAttribute("href") || "";
const slug = raw.includes("?") ? raw.split("?").pop() : raw.replace(/^\?/, "");
const [base, frag] = slug.split("#");
history.pushState(null, "", `?${base}${frag ? `#${frag}` : ""}`);
this.eventBus.emit("navigation:requested", {
slug: base,
hash: frag ? `#${frag}` : "",
});
});
}
readLocation() {
const search = window.location.search;
const hash = window.location.hash || "";
const slug =
search === "" || search === "?"
? this.getDefaultPage?.() || "home"
: search.replace(/^\?/, "").split("#")[0];
return { slug, hash };
}
navigate(slug, { replace = false } = {}) {
const [base, frag] = String(slug || "").split("#");
const url = `?${base}${frag ? `#${frag}` : ""}`;
if (replace) history.replaceState(null, "", url);
else history.pushState(null, "", url);
this.eventBus.emit("navigation:requested", {
slug: base,
hash: frag ? `#${frag}` : hashFromUrl(),
});
}
}
function hashFromUrl() {
return window.location.hash || "";
}

View File

@@ -0,0 +1,100 @@
import { slugifyHeader } from "../markdown/slug.js";
/** Title/header search with a small LRU result cache. */
export class SearchService {
constructor(eventBus, indexService) {
this.eventBus = eventBus;
this.indexService = indexService;
this.searchIndex = [];
this.searchCache = new Map();
this.cacheMaxSize = 50;
}
buildSearchIndex(documents) {
this.searchIndex = [];
this.searchCache.clear();
this.processDocuments(documents);
}
processDocuments(documents, parentPath = "") {
for (const doc of documents || []) {
if (doc.type === "folder") this.processFolderDocument(doc, parentPath);
else this.processFileDocument(doc, parentPath);
}
}
processFolderDocument(doc, parentPath) {
const currentPath = parentPath ? `${parentPath} / ${doc.title}` : doc.title;
if (doc.path) {
if (doc.showfolderpage !== "false") {
this.searchIndex.push({
title: doc.title,
path: doc.path,
slug: doc.slug,
location: currentPath,
type: "folder",
});
}
if (doc.headers) this.addHeadersToIndex(doc, currentPath, doc.title);
}
if (doc.items) this.processDocuments(doc.items, currentPath);
}
processFileDocument(doc, parentPath) {
this.searchIndex.push({
title: doc.title,
path: doc.path,
slug: doc.slug,
location: parentPath,
type: "file",
});
if (doc.headers) this.addHeadersToIndex(doc, parentPath, doc.title);
}
addHeadersToIndex(doc, location, docTitle) {
for (const header of doc.headers || []) {
this.searchIndex.push({
title: header,
path: doc.path,
slug: `${doc.slug}#${slugifyHeader(header)}`,
location: `${location} / ${docTitle}`,
type: "header",
});
}
}
search(query) {
if (!query) return [];
const q = query.toLowerCase();
if (this.searchCache.has(q)) return this.searchCache.get(q);
const results = this.searchIndex
.map((item) => this.scoreItem(item, q))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10);
if (this.searchCache.size >= this.cacheMaxSize) {
const firstKey = this.searchCache.keys().next().value;
this.searchCache.delete(firstKey);
}
this.searchCache.set(q, results);
return results;
}
scoreItem(item, query) {
const titleLower = item.title.toLowerCase();
let score = 0;
if (titleLower === query) score = 100;
else if (titleLower.startsWith(query)) score = 80;
else if (titleLower.includes(query)) score = 60;
else if (item.path?.toLowerCase().includes(query)) score = 40;
else if (item.location?.toLowerCase().includes(query)) score = 20;
if (item.type === "header") score += 5;
return { ...item, score };
}
clearCache() {
this.searchCache.clear();
}
}

View File

@@ -0,0 +1,309 @@
--- html ---
<verticalbox class="docs-nav-shell" data-content gap="2" width="100%"></verticalbox>
--- style ---
self {
display: block; /* umc-layout-ok */
box-sizing: border-box;
width: 100%; /* umc-layout-ok */
color: inherit;
font: inherit;
min-width: 0;
}
.docs-nav-folder {
--widget-padding: 0;
--widget-border: none;
--widget-background: transparent;
}
.docs-nav-folder-face {
--widget-padding: 0.4rem 0.8rem;
--widget-radius: 4px;
--widget-border: none;
--widget-background: transparent;
cursor: pointer;
}
.docs-nav-folder-face:hover {
--widget-background: rgba(255, 255, 255, 0.04);
background: var(--widget-background);
}
.docs-nav-folder-label {
font-size: 0.875rem;
font-weight: 600;
}
.docs-nav-folder-body {
padding-left: 0.5rem; /* umc-layout-ok */
}
.docs-nav-link {
--widget-padding: 0.4rem 0.8rem;
--widget-radius: 4px;
--widget-border: none;
--widget-background: transparent;
cursor: pointer;
}
.docs-nav-link:hover {
--widget-background: rgba(255, 255, 255, 0.04);
background: var(--widget-background);
}
.docs-nav-link[active] {
--widget-background: color-mix(in srgb, var(--accent, #2196f3) 28%, transparent);
background: var(--widget-background);
color: #fff;
}
.docs-nav-link-label {
font-size: 0.875rem;
}
.docs-nav-link-icon,
.docs-nav-folder-icon {
color: var(--muted, #a0a0a0);
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-nav-link[active] .docs-nav-link-icon {
color: inherit;
}
--- script ---
/**
* Document tree for the left rail.
* Uses index.json icons (FA class strings) when present — same as classic Docs-Viewer.
* Attrs: selected (slug)
* Events: navigate { slug }
*/
function folderOpen(doc, selected, parents) {
if (doc.defaultOpen) return true;
if (selected && parents.has(doc.slug)) return true;
return false;
}
function parentSlugSet(documents, selectedSlug) {
const parents = new Set();
if (!selectedSlug) return parents;
walk(documents, []);
function walk(docs, trail) {
for (const doc of docs || []) {
if (doc.slug === selectedSlug) {
for (const s of trail) parents.add(s);
return true;
}
if (doc.type === "folder" && doc.items) {
if (walk(doc.items, [...trail, doc.slug])) return true;
}
}
return false;
}
return parents;
}
/** FA class list from index.json → slate-icon name (fas:plug, fab:github, …). */
function faToSlateIcon(raw, fallback = "") {
const parts = String(raw || "")
.split(/\s+/)
.filter(Boolean);
if (!parts.length) return fallback;
const namePart = parts.find(
(p) =>
p.startsWith("fa-") &&
!["fa-solid", "fa-brands", "fa-regular", "fa-sharp", "fas", "fab", "far", "fa"].includes(p)
);
if (!namePart) return fallback;
let name = namePart.replace(/^fa-/, "");
// Classic Docs-Viewer used file-alt; FA6 free solid aliases file-lines.
if (name === "file-alt") name = "file-lines";
if (parts.some((p) => p.includes("brands") || p === "fab")) return `fab:${name}`;
if (parts.some((p) => p.includes("regular") || p === "far")) return `far:${name}`;
return `fas:${name}`;
}
function folderIcon(doc, open) {
if (doc.icon) return faToSlateIcon(doc.icon, "fas:folder");
return open ? "fas:folder-open" : "fas:folder";
}
function fileIcon(doc) {
if (!doc.icon) return "";
return faToSlateIcon(doc.icon, "");
}
function linkSpec(doc, selected) {
const icon = fileIcon(doc);
const row = [];
if (icon) {
row.push({
tag: "slate-icon",
class: "docs-nav-link-icon",
name: icon,
size: "13",
});
}
row.push({
tag: "textblock",
class: "docs-nav-link-label",
text: doc.title || doc.slug,
});
return {
tag: "border",
class: "docs-nav-link",
active: doc.slug === selected ? "" : undefined,
role: "link",
tabindex: "0",
"data-slug": doc.slug,
children: [
{
tag: "horizontalbox",
gap: icon ? "8" : "0",
valign: "center",
children: row,
},
],
};
}
function folderSpec(doc, selected, parents) {
const open = folderOpen(doc, selected, parents);
const kids = [];
if (doc.path && doc.showfolderpage !== "false") kids.push(linkSpec(doc, selected));
for (const item of doc.items || []) {
kids.push(item.type === "folder" ? folderSpec(item, selected, parents) : linkSpec(item, selected));
}
const custom = !!doc.icon;
return {
tag: "verticalbox",
class: "docs-nav-folder",
gap: "2",
"data-folder": doc.slug,
"data-folder-custom-icon": custom ? "" : undefined,
children: [
{
tag: "border",
class: "docs-nav-folder-face",
role: "button",
tabindex: "0",
"data-toggle-folder": doc.slug,
children: [
{
tag: "horizontalbox",
gap: "8",
valign: "center",
children: [
{
tag: "slate-icon",
class: "docs-nav-folder-icon",
name: folderIcon(doc, open),
size: "13",
"data-folder-icon": "",
},
{
tag: "textblock",
class: "docs-nav-folder-label",
text: doc.title || doc.slug,
},
],
},
],
},
{
tag: "verticalbox",
class: "docs-nav-folder-body",
gap: "2",
hidden: open ? undefined : "",
"data-folder-body": doc.slug,
children: kids,
},
],
};
}
function buildTree(documents, selected) {
const parents = parentSlugSet(documents, selected);
return (documents || []).map((doc) =>
doc.type === "folder" ? folderSpec(doc, selected, parents) : linkSpec(doc, selected)
);
}
export default defineUmc({
tag: "slate-docs-nav",
attrs: {
selected: "",
documents: "",
},
Construct(el, api) {
el.setDocuments = (docs) => {
el.__documents = Array.isArray(docs) ? docs : [];
el.__navDirty = true;
sync(el, api);
};
el.addEventListener("click", (e) => {
const toggle = e.target.closest?.("[data-toggle-folder]");
if (toggle && el.contains(toggle)) {
const slug = toggle.getAttribute("data-toggle-folder");
const folder = el.querySelector(`[data-folder="${CSS.escape(slug)}"]`);
const body = el.querySelector(`[data-folder-body="${CSS.escape(slug)}"]`);
const icon = toggle.querySelector("[data-folder-icon]");
if (body) {
const open = body.hasAttribute("hidden");
if (open) body.removeAttribute("hidden");
else body.setAttribute("hidden", "");
// Classic Docs-Viewer: only default folder icons swap open/closed.
if (icon && folder && !folder.hasAttribute("data-folder-custom-icon")) {
icon.setAttribute("name", open ? "fas:folder-open" : "fas:folder");
}
}
return;
}
const link = e.target.closest?.("[data-slug]");
if (link && el.contains(link)) {
api.emit("navigate", { slug: link.getAttribute("data-slug") });
}
});
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const link = e.target.closest?.("[data-slug], [data-toggle-folder]");
if (!link || !el.contains(link)) return;
e.preventDefault();
link.click();
});
},
SynchronizeProperties(el, api) {
sync(el, api);
},
});
function sync(el, api) {
let docs = el.__documents;
if (!docs) {
const raw = el.getAttribute("documents") || "";
if (raw) {
try {
docs = JSON.parse(raw);
} catch {
docs = [];
}
} else {
docs = [];
}
}
const selected = el.getAttribute("selected") || "";
const key = `${selected}::${docs.length}::${el.__navDirty || false}`;
if (el.__navKey === key && !el.__navDirty) return;
el.__navKey = key;
el.__navDirty = false;
el.set(...buildTree(docs, selected));
}
--- preview ---
<slate-docs-nav selected="welcome"></slate-docs-nav>

View File

@@ -0,0 +1,124 @@
--- html ---
<verticalbox class="docs-outline-shell" data-content gap="2" width="100%"></verticalbox>
--- style ---
self {
display: block; /* umc-layout-ok */
box-sizing: border-box;
width: 100%; /* umc-layout-ok */
color: inherit;
font: inherit;
min-width: 0;
}
.docs-outline-empty {
font-size: 12px;
color: var(--muted, #a0a0a0);
}
.docs-outline-link {
--widget-padding: 0.35rem 0.75rem;
--widget-radius: 0;
--widget-border: none;
--widget-background: transparent;
cursor: pointer;
border-left: 2px solid transparent;
}
.docs-outline-link:hover {
color: #fff;
}
.docs-outline-link[active] {
border-left-color: var(--accent, #2196f3);
color: #fff;
}
.docs-outline-label {
font-size: 12.5px;
line-height: 1.35;
color: var(--muted, #a0a0a0);
}
.docs-outline-link[active] .docs-outline-label,
.docs-outline-link:hover .docs-outline-label {
color: inherit;
}
--- script ---
/**
* On-page heading outline.
* Call el.setHeadings([{ id, level, text }]).
* Attrs: active (heading id)
* Events: navigate { id }
*/
function outlineSpecs(headings, active) {
if (!headings?.length) {
return [{ tag: "textblock", class: "docs-outline-empty", text: "" }];
}
return headings
.filter((h) => h.level >= 2)
.map((h) => ({
tag: "border",
class: "docs-outline-link",
active: h.id && h.id === active ? "" : undefined,
role: "link",
tabindex: "0",
"data-outline-id": h.id,
style: { paddingLeft: `${10 + Math.max(0, h.level - 2) * 10}px` },
children: [
{
tag: "textblock",
class: "docs-outline-label",
text: h.text || h.id,
},
],
}));
}
export default defineUmc({
tag: "slate-docs-outline",
attrs: {
active: "",
},
Construct(el, api) {
el.__headings = [];
el.setHeadings = (list) => {
el.__headings = Array.isArray(list) ? list : [];
el.__outlineDirty = true;
sync(el);
};
el.addEventListener("click", (e) => {
const link = e.target.closest?.("[data-outline-id]");
if (!link || !el.contains(link)) return;
api.emit("navigate", { id: link.getAttribute("data-outline-id") || "" });
});
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const link = e.target.closest?.("[data-outline-id]");
if (!link || !el.contains(link)) return;
e.preventDefault();
link.click();
});
},
SynchronizeProperties(el) {
sync(el);
},
});
function sync(el) {
const active = el.getAttribute("active") || "";
const key = `${active}::${el.__headings?.length || 0}::${el.__outlineDirty || false}`;
if (el.__outlineKey === key && !el.__outlineDirty) return;
el.__outlineKey = key;
el.__outlineDirty = false;
el.set(...outlineSpecs(el.__headings || [], active));
}
--- preview ---
<slate-docs-outline></slate-docs-outline>

View File

@@ -0,0 +1,371 @@
--- html ---
<canvaspanel class="docs-viewer-root" clip="false" height="100%">
<horizontalbox class="docs-viewer-shell" anchors="fill" top="0" left="0" right="0" bottom="0" gap="0">
<slate-side-bar
id="docs-viewer-sidebar"
class="docs-viewer-nav"
width="300"
collapse-at="1000"
placement="left"
>
<verticalbox gap="0" height="100%">
<verticalbox class="docs-viewer-brand" gap="10" padding="28 20 20" halign="center">
<horizontalbox gap="8" valign="center" width="100%">
<border
class="docs-viewer-menu"
sidebar="docs-viewer-sidebar"
padding="4 6"
role="button"
tabindex="0"
aria-label="Close navigation"
>
<slate-icon name="menu" size="16"></slate-icon>
</border>
<verticalbox gap="10" fill min-width="0" halign="center">
<img class="docs-viewer-logo-img" data-docs-logo src="./img/logo.png" alt="" />
<textblock class="docs-viewer-logo" data-docs-site-name text="" hidden></textblock>
<textblock class="docs-viewer-author-role" data-docs-author-role text=""></textblock>
</verticalbox>
</horizontalbox>
</verticalbox>
<verticalbox class="docs-viewer-search-block" gap="0" padding="4 16 16">
<horizontalbox class="docs-viewer-search-box" gap="8" valign="center" padding="0 10" height="36">
<slate-icon name="search" size="14"></slate-icon>
<editabletext class="docs-viewer-search-input" data-docs-search fill></editabletext>
<textblock class="docs-viewer-search-hint" text="Alt+S"></textblock>
</horizontalbox>
<verticalbox class="docs-viewer-search-panel" data-docs-search-panel hidden gap="0" padding="4"></verticalbox>
</verticalbox>
<scrollbox class="docs-viewer-nav-scroll" fill padding="12 8 16">
<slate-docs-nav data-docs-nav></slate-docs-nav>
</scrollbox>
<verticalbox class="docs-viewer-footer" gap="0">
<slate-bottom-nav
class="docs-viewer-socials"
data-docs-socials
kind="transparent"
labels="hide"
></slate-bottom-nav>
</verticalbox>
</verticalbox>
</slate-side-bar>
<canvaspanel class="docs-viewer-main" fill min-width="0" min-height="0">
<verticalbox anchors="fill" top="0" left="0" right="0" bottom="0" gap="0">
<border class="docs-viewer-progress-wrap" padding="0" hidden data-docs-progress>
<slate-progress indeterminate></slate-progress>
</border>
<scrollbox class="docs-viewer-article-scroll" fill data-docs-article-scroll>
<verticalbox
class="docs-viewer-article"
padding="32 48"
gap="0"
width="100%"
max-width="920"
min-width="280"
data-docs-article
>
<slate-markdown data-docs-markdown></slate-markdown>
<slate-alert kind="error" hidden data-docs-error></slate-alert>
</verticalbox>
</scrollbox>
</verticalbox>
<border
class="docs-viewer-menu-fab"
sidebar="docs-viewer-sidebar"
anchors="top-left"
top="12"
left="12"
padding="8"
role="button"
tabindex="0"
aria-label="Open navigation"
>
<slate-icon name="menu" size="18"></slate-icon>
</border>
</canvaspanel>
<slate-side-bar
class="docs-viewer-outline-rail"
width="240"
collapse-at="1100"
placement="right"
mode="rail"
>
<scrollbox fill padding="20 10 20">
<slate-docs-outline data-docs-outline></slate-docs-outline>
</scrollbox>
</slate-side-bar>
</horizontalbox>
</canvaspanel>
--- style ---
self {
display: block; /* umc-layout-ok */
box-sizing: border-box;
width: 100%; /* umc-layout-ok */
height: 100%; /* umc-layout-ok */
min-height: 100vh; /* umc-layout-ok, app shell */
color: var(--ink, #e0e0e0);
background: var(--bg, #1a1a1a);
font-family: var(--slate-font, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
--accent: var(--docs-accent, #2196f3);
--line: var(--docs-line, #404040);
--panel: var(--docs-panel, #252526);
--muted: var(--docs-muted, #a0a0a0);
}
.docs-viewer-root,
.docs-viewer-shell {
height: 100%; /* umc-layout-ok */
}
.docs-viewer-brand {
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-viewer-logo-img {
display: block; /* umc-layout-ok */
height: 24px; /* umc-layout-ok — OG .brand-logo */
width: auto; /* umc-layout-ok */
max-width: 100%; /* umc-layout-ok */
object-fit: contain;
}
.docs-viewer-logo-img[hidden] {
display: none !important;
}
.docs-viewer-menu {
--widget-background: transparent;
--widget-border: none;
cursor: pointer;
display: none; /* umc-layout-ok, drawer only */
}
self:has(#docs-viewer-sidebar[layout="drawer"][open]) .docs-viewer-menu {
display: block; /* umc-layout-ok */
}
.docs-viewer-menu-fab {
--widget-background: var(--panel);
--widget-border: 1px solid var(--line);
--widget-radius: 8px;
cursor: pointer;
z-index: 15; /* umc-layout-ok */
display: none; /* umc-layout-ok */
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
self:has(#docs-viewer-sidebar[layout="drawer"]:not([open])) .docs-viewer-menu-fab {
display: block; /* umc-layout-ok */
}
.docs-viewer-logo {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.03em;
text-align: center;
}
.docs-viewer-author-role {
font-size: 0.75rem;
color: var(--muted);
line-height: 1.35;
text-align: center;
width: 100%; /* umc-layout-ok — center multiline taglines */
}
.docs-viewer-nav {
--sidebar-width: 300px;
border-right: 1px solid var(--line);
background: var(--panel);
}
.docs-viewer-outline-rail {
--sidebar-width: 240px;
--sidebar-bg: var(--bg, #1a1a1a);
border: none;
background: var(--bg, #1a1a1a);
}
.docs-viewer-outline-rail .slate-sidebar-panel {
--widget-background: var(--bg, #1a1a1a);
background: var(--bg, #1a1a1a);
border: none !important;
}
.docs-viewer-search-block {
border-bottom: 1px solid var(--line);
position: relative; /* umc-layout-ok, anchor results */
z-index: 20; /* umc-layout-ok */
}
.docs-viewer-search-box {
--widget-background: var(--bg);
--widget-border: 1px solid var(--line);
--widget-radius: 6px;
--widget-padding: 0 10px;
background: var(--widget-background);
border: var(--widget-border);
border-radius: var(--widget-radius);
}
.docs-viewer-search-input {
--widget-border: none;
--widget-background: transparent;
--widget-padding: 0;
font-size: 14px;
min-height: 34px; /* umc-layout-ok */
color: inherit;
}
.docs-viewer-search-hint {
font-size: 11px;
color: var(--muted);
opacity: 0.7;
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-viewer-search-panel {
position: absolute; /* umc-layout-ok */
left: 12px; /* umc-layout-ok */
right: 12px; /* umc-layout-ok */
top: calc(100% - 4px); /* umc-layout-ok */
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
max-height: min(50vh, 360px); /* umc-layout-ok */
overflow: auto; /* umc-layout-ok */
z-index: 30; /* umc-layout-ok */
}
.docs-viewer-search-panel[hidden] {
display: none !important;
}
.docs-search-hit {
--widget-padding: 8px 10px;
--widget-radius: 0;
--widget-border: none;
--widget-background: transparent;
cursor: pointer;
border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
}
.docs-search-hit:last-child {
border-bottom: none;
}
.docs-search-hit:hover {
--widget-background: rgba(33, 150, 243, 0.12);
background: var(--widget-background);
}
.docs-search-hit-title {
font-size: 13px;
font-weight: 550;
}
.docs-search-hit-meta {
font-size: 11px;
color: var(--muted);
white-space: nowrap; /* umc-layout-ok */
overflow: hidden; /* umc-layout-ok */
text-overflow: ellipsis; /* umc-layout-ok */
}
.docs-viewer-footer {
border-top: 1px solid var(--line);
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-viewer-socials {
--widget-background: transparent;
--widget-border: none;
--widget-padding: 4px 6px 8px;
border-top: none;
}
.docs-viewer-socials slate-bottom-nav-item {
opacity: 0.7;
}
.docs-viewer-socials slate-bottom-nav-item:hover {
opacity: 1;
color: var(--accent, #2196f3);
}
.docs-viewer-article-scroll {
background: var(--bg);
}
.docs-viewer-progress-wrap {
--widget-padding: 0;
--widget-border: none;
--widget-radius: 0;
--widget-background: transparent;
}
@media print {
.docs-viewer-nav,
.docs-viewer-outline-rail,
.docs-viewer-menu,
.docs-viewer-menu-fab,
.docs-viewer-search-block,
.docs-viewer-footer,
.docs-viewer-progress-wrap {
display: none !important;
}
self {
height: auto; /* umc-layout-ok */
min-height: 0;
background: #fff;
color: #111;
}
}
--- script ---
/**
* Documentation viewer shell.
*
* import { mountDocs } from "docs-viewer";
* mountDocs(document.querySelector("slate-docs-viewer"));
*/
export default defineUmc({
tag: "slate-docs-viewer",
attrs: {
index: "./index.json",
logo: "./img/logo.png",
},
Construct(el) {
el.setAttribute("role", "application");
el.setAttribute("aria-label", "Documentation viewer");
},
SynchronizeProperties(el) {
const logoUrl = (el.getAttribute("logo") || "./img/logo.png").trim();
const img = el.querySelector("[data-docs-logo]");
const text = el.querySelector("[data-docs-site-name]");
if (img) {
if (logoUrl) {
img.setAttribute("src", logoUrl);
img.removeAttribute("hidden");
text?.setAttribute("hidden", "");
} else {
img.setAttribute("hidden", "");
text?.removeAttribute("hidden");
}
}
},
});
--- preview ---
<slate-docs-viewer index="./index.json"></slate-docs-viewer>

View File

@@ -0,0 +1,100 @@
--- html ---
<verticalbox class="slate-markdown-shell" data-content gap="0" width="100%"></verticalbox>
--- style ---
self {
display: block; /* umc-layout-ok */
box-sizing: border-box;
width: 100%; /* umc-layout-ok */
color: inherit;
font: inherit;
min-width: 0;
}
.self-loading {
opacity: 0.65;
}
.md-code-block {
--widget-padding: 1rem;
--widget-radius: 8px;
--widget-background: var(--panel, #252526);
--widget-border: 1px solid var(--line, #404040);
background: var(--widget-background);
border: var(--widget-border);
border-radius: var(--widget-radius);
margin: 1rem 0; /* umc-layout-ok */
overflow-x: auto; /* umc-layout-ok */
box-sizing: border-box;
}
.md-code-block .md-code,
.md-code-block slate-rich-text {
margin: 0;
}
.md-blockquote {
--widget-border: none;
--widget-radius: 4px;
border-left: 4px solid var(--accent, currentColor);
margin: 1rem 0; /* umc-layout-ok */
opacity: 0.92;
}
.md-hr {
margin: 3rem 0; /* umc-layout-ok, OG hr */
}
--- script ---
import { markdownToSpecs, collectHeadings } from "../markdown/render.js";
/**
* Markdown → slate block tree (inline via slate-rich-text).
*
* <slate-markdown content="# Hello"></slate-markdown>
*
* Attrs: content (markdown source)
* Events: rendered { headings }, headingclicked { id, text }
*/
export default defineUmc({
tag: "slate-markdown",
attrs: {
content: "",
},
Construct(el, api) {
el.addEventListener("click", (e) => {
const heading = e.target.closest?.("[data-heading]");
if (!heading || !el.contains(heading)) return;
if (e.target.closest("a, button")) return;
const id = heading.id;
if (!id) return;
api.emit("headingclicked", {
id,
text: heading.getAttribute("data-heading-text") || heading.textContent || "",
});
});
},
SynchronizeProperties(el, api) {
const content = el.getAttribute("content") || "";
if (el.__mdContent === content) return;
el.__mdContent = content;
const specs = markdownToSpecs(content);
el.set(...specs);
const headings = collectHeadings(el);
api.emit("rendered", { headings });
},
});
--- preview ---
<slate-markdown
content="# Preview
Hello **world** and a [link](?welcome).
```js
console.log('hi')
```
"
></slate-markdown>

View File

@@ -0,0 +1,195 @@
--- html ---
--- style ---
self {
display: block; /* umc-layout-ok, prose leaf */
box-sizing: border-box;
width: 100%; /* umc-layout-ok */
color: inherit;
font: inherit;
font-size: 1rem;
line-height: 1.7;
min-width: 0;
}
self[kind="h1"] {
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.2;
margin: 0 0 1.5rem; /* umc-layout-ok, OG .markdown-content h1 */
cursor: pointer;
}
self[kind="h2"] {
font-size: 1.8rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
margin: 0; /* umc-layout-ok — slate-collapse supplies section rhythm */
cursor: pointer;
flex: 1 1 auto; /* umc-layout-ok */
min-width: 0; /* umc-layout-ok */
}
self[kind="h3"] {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.35;
margin: 0; /* umc-layout-ok */
cursor: pointer;
flex: 1 1 auto; /* umc-layout-ok */
min-width: 0; /* umc-layout-ok */
}
self[kind="h4"],
self[kind="h5"],
self[kind="h6"] {
font-size: 1.1rem;
font-weight: 600;
margin: 1.25em 0 0.4em; /* umc-layout-ok */
cursor: pointer;
}
self[kind="paragraph"] {
font-size: 1rem;
line-height: 1.7;
margin: 1rem 0; /* umc-layout-ok, OG p */
}
self[kind="list"],
self[kind="table"],
self[kind="html"],
self[kind="code"] {
margin: 1rem 0; /* umc-layout-ok */
}
self[kind="code"] {
margin: 0;
}
self a {
color: var(--accent, #2196f3);
text-decoration: none;
}
self a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
self code {
font-family: var(--slate-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 0.9em;
padding: 0.2em 0.4em; /* umc-layout-ok */
border-radius: 1px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e0e0e0;
}
self pre {
display: block; /* umc-layout-ok */
margin: 0;
font-family: var(--slate-font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
font-size: 0.9rem;
line-height: 1.5;
white-space: pre;
overflow-x: auto; /* umc-layout-ok */
background: transparent;
border: none;
padding: 0; /* umc-layout-ok — chrome lives on .md-code-block */
}
self pre code,
self pre code.hljs,
self code.hljs {
display: block; /* umc-layout-ok */
padding: 0; /* umc-layout-ok */
margin: 0;
background: none;
border: none;
border-radius: 0;
font-size: 0.9rem;
line-height: 1.5;
color: inherit;
white-space: inherit;
}
self img,
self video {
max-width: 100%; /* umc-layout-ok */
height: auto; /* umc-layout-ok */
border-radius: 8px;
display: block; /* umc-layout-ok */
margin: 1rem 0; /* umc-layout-ok */
}
self ul,
self ol {
margin: 1rem 0; /* umc-layout-ok */
padding-left: 1.35em; /* umc-layout-ok */
}
self li {
margin: 0.35em 0; /* umc-layout-ok */
}
self table {
width: 100%; /* umc-layout-ok */
border-collapse: separate;
border-spacing: 0;
font-size: 0.9rem;
margin: 1em 0; /* umc-layout-ok */
}
self th,
self td {
border: none;
border-bottom: 1px solid var(--line, #404040);
padding: 0.75rem; /* umc-layout-ok */
text-align: left;
}
self th {
font-weight: 600;
background: rgba(255, 255, 255, 0.15);
}
self td {
background: rgba(255, 255, 255, 0.03);
}
--- script ---
import { decorateHtml } from "../markdown/decorate.js";
/**
* Inline / HTML island leaf for markdown.
*
* <slate-rich-text kind="paragraph" html="Hello <strong>world</strong>"></slate-rich-text>
*
* Attrs: html, kind (h1…h6 | paragraph | list | table | code | html)
*/
export default defineUmc({
tag: "slate-rich-text",
attrs: {
html: "",
kind: "paragraph",
},
SynchronizeProperties(el) {
const next = decorateHtml(el.getAttribute("html") || "");
if (el.__richHtml === next) return;
el.__richHtml = next;
el.innerHTML = next;
},
});
--- preview ---
<verticalbox gap="8" padding="16" width="420">
<slate-rich-text kind="h2" html="Heading with <code>code</code>"></slate-rich-text>
<slate-rich-text
kind="paragraph"
html='Body with <strong>bold</strong>, <em>italic</em>, and a <a href="?welcome" data-internal="true">wiki link</a>.'
></slate-rich-text>
</verticalbox>

1175
styles.css

File diff suppressed because it is too large Load Diff

62
vite.config.js Normal file
View File

@@ -0,0 +1,62 @@
import { defineConfig } from "vite";
import { createRequire } from "node:module";
import { cpSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { umc } from "slatehtml/umc/vite";
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
const uiSrc = join(dirname(require.resolve("slatehtml-ui/package.json")), "src");
const docsSrc = join(here, "src");
function copyDocsStatic() {
const paths = ["index.json", "docs", "img", "example.index.json"];
return {
name: "copy-docs-static",
writeBundle(outputOptions) {
const outDir = outputOptions.dir || join(here, "dist");
for (const name of paths) {
const from = join(here, name);
if (!existsSync(from)) continue;
cpSync(from, join(outDir, name), { recursive: true });
}
},
};
}
export default defineConfig({
root: here,
base: "./",
plugins: [umc({ roots: [uiSrc, docsSrc] }), copyDocsStatic()],
server: {
fs: {
allow: [here, join(here, "..", "slatehtml")],
},
},
build: {
target: "es2022",
outDir: "dist",
emptyOutDir: true,
},
resolve: {
alias: [
{
find: "slatehtml-ui/configure",
replacement: join(uiSrc, "configure.js"),
},
{
find: "slatehtml-ui/icons/fontawesome",
replacement: join(uiSrc, "fontawesome-icons.js"),
},
{
find: "slatehtml-ui/icons/lucide",
replacement: join(uiSrc, "lucide-icons.js"),
},
{
find: "docs-viewer/prose.css",
replacement: join(docsSrc, "prose.css"),
},
],
},
});