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:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
- slatehtml
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -11,15 +12,15 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: "22"
|
||||||
|
|
||||||
- name: Build docs
|
- name: Build docs index
|
||||||
run: node build-docs.js
|
run: node build-docs.cjs
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
id: check_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
|
# 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
|
```bash
|
||||||
npm install
|
npm install github:litruv/Docs-Viewer#slatehtml
|
||||||
|
# peers / siblings as needed:
|
||||||
|
npm install slatehtml slatehtml-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Copy the example configuration:
|
Local sibling checkout:
|
||||||
```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
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"defaultPage": "home",
|
"dependencies": {
|
||||||
"showDocsLink": true,
|
"docs-viewer": "file:../Docs-Viewer",
|
||||||
"metadata": {
|
"slatehtml": "file:../slatehtml",
|
||||||
"title": "Site Title",
|
"slatehtml-ui": "file:../slatehtml/packages/slatehtml-ui"
|
||||||
"description": "Site description",
|
|
||||||
"site_name": "Documentation"
|
|
||||||
},
|
|
||||||
"author": {
|
|
||||||
"name": "Author Name",
|
|
||||||
"role": "Role",
|
|
||||||
"socials": [
|
|
||||||
{
|
|
||||||
"icon": "fab fa-github",
|
|
||||||
"url": "https://github.com/username",
|
|
||||||
"title": "GitHub"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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`
|
```html
|
||||||
## Contributing
|
<body>
|
||||||
|
<slate-docs-viewer index="./index.json"></slate-docs-viewer>
|
||||||
|
<script type="module" src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
```
|
||||||
|
|
||||||
1. Fork the repository
|
```js
|
||||||
2. Create a feature branch
|
import "slatehtml";
|
||||||
3. Submit a pull request
|
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
|
Vite needs the UMC plugin with UI + this package’s `src`:
|
||||||
- [highlight.js](https://highlightjs.org/) - Syntax highlighting
|
|
||||||
- [Font Awesome](https://fontawesome.com/) - Icons
|
|
||||||
|
|
||||||
## 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>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title></title>
|
<title>Docs Viewer</title>
|
||||||
|
<style>
|
||||||
<!--ogmetadata-->
|
html,
|
||||||
|
body {
|
||||||
<link rel="stylesheet" href="styles.css">
|
margin: 0;
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
|
height: 100%;
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github-dark.min.css">
|
background: #1e1e2e;
|
||||||
|
color: #c6d0f5;
|
||||||
</head>
|
}
|
||||||
<body>
|
html:not([data-ready]) {
|
||||||
<!-- Add skip to content link -->
|
visibility: hidden;
|
||||||
<a href="#document-content" class="skip-to-content">Skip to content</a>
|
}
|
||||||
|
</style>
|
||||||
<div class="container">
|
</head>
|
||||||
<div class="title-bar" role="banner">
|
<body>
|
||||||
<button class="menu-button" aria-label="Toggle Menu" aria-expanded="false" aria-controls="left-sidebar">
|
<slate-docs-viewer index="./index.json"></slate-docs-viewer>
|
||||||
<i class="fas fa-bars" aria-hidden="true"></i>
|
<script type="module" src="./demo/main.js"></script>
|
||||||
</button>
|
</body>
|
||||||
<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>
|
|
||||||
</html>
|
</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",
|
"name": "docs-viewer",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "Documentation viewer application",
|
"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": {
|
"scripts": {
|
||||||
"start": "live-server --port=8080 --no-browser",
|
"dev": "vite",
|
||||||
"build": "node build-docs.js"
|
"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": {
|
"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