mirror of
https://github.com/litruv/Docs-Viewer.git
synced 2026-09-10 01:59:44 +10:00
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:
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
@@ -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
5
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
/node_modules
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
288
README.md
288
README.md
@@ -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
|
||||

|
||||
# 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 package’s `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
24
demo/main.js
Normal 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
26
demo/site.css
Normal 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);
|
||||
}
|
||||
197
index.html
197
index.html
@@ -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);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
</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>
|
||||
<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;
|
||||
}
|
||||
html:not([data-ready]) {
|
||||
visibility: hidden;
|
||||
}
|
||||
</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
37
index.json
Normal 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": []
|
||||
}
|
||||
576
js/DOMService.js
576
js/DOMService.js
@@ -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.
|
||||
*/
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
3380
package-lock.json
generated
3380
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
52
package.json
52
package.json
@@ -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
425
src/app.js
Normal 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
73
src/index.js
Normal 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
24
src/markdown/decorate.js
Normal 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;
|
||||
}
|
||||
60
src/markdown/preprocess.js
Normal file
60
src/markdown/preprocess.js
Normal 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\n\n`;
|
||||
});
|
||||
}
|
||||
263
src/markdown/render.js
Normal file
263
src/markdown/render.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** 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
7
src/markdown/slug.js
Normal 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
89
src/prose.css
Normal 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);
|
||||
}
|
||||
77
src/services/document-service.js
Normal file
77
src/services/document-service.js
Normal 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
22
src/services/event-bus.js
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/services/index-service.js
Normal file
76
src/services/index-service.js
Normal 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;
|
||||
}
|
||||
54
src/services/navigation-service.js
Normal file
54
src/services/navigation-service.js
Normal 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 || "";
|
||||
}
|
||||
100
src/services/search-service.js
Normal file
100
src/services/search-service.js
Normal 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();
|
||||
}
|
||||
}
|
||||
309
src/widgets/slate-docs-nav.umc
Normal file
309
src/widgets/slate-docs-nav.umc
Normal 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>
|
||||
124
src/widgets/slate-docs-outline.umc
Normal file
124
src/widgets/slate-docs-outline.umc
Normal 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>
|
||||
371
src/widgets/slate-docs-viewer.umc
Normal file
371
src/widgets/slate-docs-viewer.umc
Normal 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>
|
||||
100
src/widgets/slate-markdown.umc
Normal file
100
src/widgets/slate-markdown.umc
Normal 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>
|
||||
195
src/widgets/slate-rich-text.umc
Normal file
195
src/widgets/slate-rich-text.umc
Normal 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
1175
styles.css
File diff suppressed because it is too large
Load Diff
62
vite.config.js
Normal file
62
vite.config.js
Normal 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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user