Compare commits

...

15 Commits

Author SHA1 Message Date
f4b40e0d97 Improve mobile chrome for the slatehtml docs shell.
Show a top bar with menu/logo/title when the nav drawer is active, hide the outline at the same breakpoint, fix the FA hamburger icon, and use a message glyph for Discord socials.
2026-08-02 21:30:35 +10:00
893d0f02be Point install docs at master after the slatehtml merge. 2026-08-02 20:51:35 +10:00
f9c43372ed Merge pull request #4 from litruv/slatehtml
Port Docs-Viewer to slatehtml
2026-08-02 20:51:00 +10:00
10e9f7ac2f 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.
2026-08-02 20:48:31 +10:00
407ee6c0d3 add show docs link to config 2026-01-02 23:53:44 +11:00
d9a6943570 Add screenshots section to README
Added screenshots section with images for Home, Mobile UI, Live Search, Document Outline Sidebar, and Print-Friendly View.
2026-01-02 23:45:37 +11:00
341e4e48ff Fix formatting 2026-01-02 13:54:17 +11:00
83c7566e6c updated readme 2026-01-02 13:49:01 +11:00
8fbfd2ae77 Expose Documentation instance to global scope for console access 2026-01-02 13:37:23 +11:00
7da85222c8 Add loading state for links in file index and folder links 2026-01-02 13:33:26 +11:00
8d0a0fa50e add roles and focus trap for mobile sidebar navigation 2026-01-02 13:29:57 +11:00
de6a866c06 Add caching + LazyLoading to searches and images 2026-01-02 13:27:17 +11:00
a091905b28 remove 'litruv' from alt text on logos 2026-01-02 13:24:35 +11:00
1d978e3244 Split up monolith index.js into multiple files 2026-01-02 13:18:20 +11:00
5764e41db3 Update package-lock.json 2026-01-02 13:07:19 +11:00
30 changed files with 3848 additions and 4792 deletions

View File

@@ -11,15 +11,15 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: "22"
- name: Build docs
run: node build-docs.js
- name: Build docs index
run: node build-docs.cjs
- name: Check for changes
id: check_changes

5
.gitignore vendored
View File

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

254
README.md
View File

@@ -1,207 +1,83 @@
# Docs Viewer
A modern, accessible documentation viewer for Markdown files with live search, navigation, and mobile support.
Static documentation viewer built on **slatehtml** + **slatehtml-ui**: panel shell, live search, wiki links, and markdown as slate blocks (inline via `slate-rich-text`).
## Features
## Install (consumer sites)
- 🔍 Live search with keyboard shortcuts (Alt+S)
- 📱 Mobile-friendly responsive design
- 🎯 Keyboard navigation support
- 📑 Auto-generated document outline
- 🔗 Wiki-style internal linking
- 🖨️ Print-friendly styling
- ♿ ARIA-compliant accessibility
- 🌙 Dark theme
## Quick Start
1. Install dependencies:
```bash
npm install
npm install github:litruv/Docs-Viewer
# peers / siblings as needed:
npm install slatehtml slatehtml-ui
```
2. Copy the example configuration:
```bash
cp example.index.json index.json
```
Then modify `index.json` with your site's metadata, author info, and social links.
3. Create your documentation structure:
```
docs/
├── images/ # Place images here
├── index.md # Main landing page
└── ... other .md files
```
4. Build the documentation index:
```bash
npm run build
```
This will scan your docs folder and update `index.json` with the document structure.
5. Start the development server:
```bash
npm start
```
## Documentation Structure
### File Organization
- Place all documentation files in the `docs/` directory
- Store images and video in `docs/images/`
- Use `.md` extension for Markdown files
### Markdown Files
Each Markdown file can include YAML frontmatter:
```markdown
---
title: Page Title
description: Page description
sort: 1 # Optional: controls sidebar order
thumbnail: images/thumb.png # Optional: for OG images
---
# Content starts here
```
### Folder Structure
To create sections, make a folder and add a matching Markdown file:
```
docs/
├── getting-started/
│ ├── getting-started.md # Folder index
│ ├── installation.md
│ └── configuration.md
└── index.md
```
## Special Features
### Wiki Links
Use double brackets for internal links:
```markdown
[[Page Title]]
[[Page Title|Custom Text]]
```
### Images
Store images in `docs/images/` and reference them:
```markdown
![Alt text](./docs/images/picture.png)
# or
![[picture.png]]
```
### Headers
Headers are automatically added to the right sidebar outline and are collapsible.
## Development
### Recommended Editor
We recommend using [Obsidian.md](https://obsidian.md) as your editor for the documentation files. The `docs/.obsidian` directory includes a custom plugin that provides enhanced editing features:
- Displays page titles in the file explorer instead of filenames
- Shows frontmatter-defined sort order in the file list
- Automatically updates file ordering based on the `sort` property
- Makes folder and document organization more intuitive
To use the plugin:
1. Open the `docs` folder as an Obsidian vault
2. The plugin will be automatically loaded
3. The file explorer will now show your document titles and sorting order
### Project Structure
```
.
├── docs/ # Documentation files
├── build-docs.js # Documentation builder
├── index.html # Main viewer
├── index.js # Application logic
└── styles.css # Styling
```
### Building
The build process:
1. Scans the `docs/` directory
2. Edits `index.json` with document metadata
A GitHub Actions workflow is included that automatically:
- Runs on every push to the master branch
- Executes the build process
- Commits and pushes any changes to `index.json`
- Ensures your documentation index stays in sync with your content
This means you can edit your documentation directly on GitHub, and the index will be automatically updated.
If you don't want to use Github actions, you can use npm run build
### Cloudflare Pages
1. Create a new repository on GitHub.
2. Push your code to the repository.
3. Go to [Cloudflare Pages](https://pages.cloudflare.com/) and connect your GitHub repository.
4. Configure the build settings:
- **Production branch:** `main` (or your main branch name)
- **Build command:** Leave empty
- **Build output directory:** `/` (root)
5. Save and deploy.
#### Optional: Cloudflare Worker for OG/Twitter Tags
For improved SEO and social sharing, you can use a Cloudflare Worker to dynamically generate OG/Twitter tags.
1. Create a new Cloudflare Worker using the code in `cloudflare-worker.js`.
2. Set the `SITE_URL` environment variable to where your site will be located, e.g., `https://example.com/docs/`
3. Set the `DOCS_URL` environment variable to the URL where your documentation files are hosted (usually your Cloudflare Pages URL).
4. Configure a route in your Cloudflare account to route all requests to your Cloudflare Pages site through the worker.
## Configuration
### index.json
Local sibling checkout:
```json
{
"defaultPage": "home",
"metadata": {
"title": "Site Title",
"description": "Site description",
"site_name": "Documentation"
},
"author": {
"name": "Author Name",
"role": "Role",
"socials": [
{
"icon": "fab fa-github",
"url": "https://github.com/username",
"title": "GitHub"
}
]
"dependencies": {
"docs-viewer": "file:../Docs-Viewer",
"slatehtml": "file:../slatehtml",
"slatehtml-ui": "file:../slatehtml/packages/slatehtml-ui"
}
}
```
The build process generates the documents part for `index.json`
## Contributing
## App usage
1. Fork the repository
2. Create a feature branch
3. Submit a pull request
```html
<body>
<slate-docs-viewer index="./index.json"></slate-docs-viewer>
<script type="module" src="./main.js"></script>
</body>
```
## License
```js
import "slatehtml";
import "slatehtml-ui/app-bar";
import "slatehtml-ui/icon";
import "slatehtml-ui/text-field";
import "slatehtml-ui/side-bar";
import "slatehtml-ui/text";
import "slatehtml-ui/progress";
import "slatehtml-ui/alert";
import "slatehtml-ui/divider";
import { configure } from "slatehtml-ui/configure";
import { fontAwesomeSvg } from "slatehtml-ui/icons/fontawesome";
import { mountDocs } from "docs-viewer";
import "docs-viewer/prose.css";
MIT License - see LICENSE file for details.
configure({ icons: fontAwesomeSvg });
await mountDocs("slate-docs-viewer");
```
Vite needs the UMC plugin with UI + this packages `src`:
```js
import { umc } from "slatehtml/umc/vite";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const uiSrc = join(dirname(require.resolve("slatehtml-ui/package.json")), "src");
const docsSrc = join(dirname(require.resolve("docs-viewer/package.json")), "src");
export default {
plugins: [umc({ roots: [uiSrc, docsSrc] })],
};
```
## Develop this repo
```bash
npm install # expects ../slatehtml checkout
npm run build:index
npm run dev
```
## Content contract
Same as before:
- `docs/**/*.md` + YAML frontmatter
- `index.json` metadata; `documents` from `npm run build:index`
- Wiki `[[Page Title]]`, embeds `![[image.png]]`

24
demo/main.js Normal file
View File

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

26
demo/site.css Normal file
View File

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

View File

@@ -1,179 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title></title>
<!--ogmetadata-->
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github-dark.min.css">
</head>
<body>
<!-- Add skip to content link -->
<a href="#document-content" class="skip-to-content">Skip to content</a>
<div class="container">
<div class="title-bar" role="banner">
<button class="menu-button" aria-label="Toggle Menu" aria-expanded="false" aria-controls="left-sidebar">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<div class="title-text">
<img src="./img/logo.png" alt="Litruv" 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">
<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"></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 src="index.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', 'Litruv logo');
printHeader.setAttribute('aria-hidden', 'true'); // Hide from screen readers when printing
});
// Clean up after printing
window.addEventListener('afterprint', function() {
const printHeader = document.querySelector('.print-header');
if (printHeader) {
printHeader.remove();
}
});
</script>
<!-- Add accessibility enhancements script -->
<script>
// Enhance keyboard navigation
document.addEventListener('DOMContentLoaded', function() {
// Make folders and navigation keyboard accessible
function enhanceTreeKeyboardNavigation() {
const folders = document.querySelectorAll('.folder-header');
const links = document.querySelectorAll('#file-index a, #document-outline a');
folders.forEach(folder => {
// Add ARIA attributes
const folderDiv = folder.closest('.folder');
const isOpen = folderDiv.classList.contains('open');
folder.setAttribute('role', 'treeitem');
folder.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
// Handle keyboard events
folder.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
folder.click();
folder.setAttribute('aria-expanded',
folderDiv.classList.contains('open') ? 'true' : 'false');
}
});
folder.setAttribute('tabindex', '0');
});
links.forEach(link => {
link.setAttribute('role', 'treeitem');
});
}
// Handle menu button accessibility
const menuButton = document.querySelector('.menu-button');
if (menuButton) {
menuButton.addEventListener('click', function() {
const expanded = document.querySelector('.left-sidebar').classList.contains('show');
menuButton.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
}
// If using a MutationObserver to watch for DOM changes, call enhanceTreeKeyboardNavigation
// after the navigation tree is populated
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length) {
enhanceTreeKeyboardNavigation();
}
});
});
observer.observe(document.getElementById('file-index'), {
childList: true,
subtree: true
});
// Initially enhance any existing navigation
enhanceTreeKeyboardNavigation();
});
</script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Docs Viewer</title>
<style>
html,
body {
margin: 0;
height: 100%;
background: #1e1e2e;
color: #c6d0f5;
}
html:not([data-ready]) {
visibility: hidden;
}
</style>
</head>
<body>
<slate-docs-viewer index="./index.json"></slate-docs-viewer>
<script type="module" src="./demo/main.js"></script>
</body>
</html>

1039
index.js

File diff suppressed because it is too large Load Diff

37
index.json Normal file
View File

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

3380
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

438
src/app.js Normal file
View File

@@ -0,0 +1,438 @@
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]"),
pageTitle: () => host.querySelector("[data-docs-page-title]"),
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 logoUrl = (data.metadata?.logo || this.host.getAttribute("logo") || "./img/logo.png").trim();
for (const logo of this.host.querySelectorAll("[data-docs-logo], [data-docs-logo-top]")) {
logo.setAttribute("alt", site);
if (logoUrl) {
logo.setAttribute("src", logoUrl);
logo.removeAttribute("hidden");
} else {
logo.setAttribute("hidden", "");
}
}
if (logoUrl) siteEl?.setAttribute("hidden", "");
else siteEl?.removeAttribute("hidden");
const pageTitle = this.$.pageTitle();
if (pageTitle && !pageTitle.getAttribute("text")) {
pageTitle.setAttribute("text", site);
}
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) => {
let icon = faClassToIconName(s.icon);
// Prefer a generic message glyph over the Discord brand mark.
if (/discord/i.test(String(s.icon || "")) || /discord/i.test(String(s.url || ""))) {
icon = "fas:message";
}
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"}`;
this.$.pageTitle()?.setAttribute("text", title || "Documentation");
const fromPath = findSlugByPath(this.indexData?.documents || [], path);
this.$.nav()?.setAttribute("selected", fromPath || slug || "");
const scroll = this.$.articleScroll();
if (hash) {
const id = hash.replace(/^#/, "");
requestAnimationFrame(() => setTimeout(() => this.scrollToId(id, false), 40));
} else if (scroll) {
scroll.scrollTop = 0;
}
} catch (err) {
this.showError(err.message || "Error loading document.");
} finally {
this.setProgress(false);
}
}
setupOutlineObserver(headings) {
this._outlineObserver?.disconnect();
const root = this.$.articleScroll();
if (!root || !headings?.length) return;
const map = new Map(headings.map((h) => [h.el, h.id]));
this._outlineObserver = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.target.offsetTop - b.target.offsetTop);
if (!visible.length) return;
const id = map.get(visible[0].target);
if (id) this.$.outline()?.setAttribute("active", id);
},
{ root, rootMargin: "-48px 0px -55% 0px", threshold: [0, 0.25, 1] }
);
for (const h of headings) {
if (h.el) this._outlineObserver.observe(h.el);
}
}
scrollToId(id, pushHash) {
const el = this.host.querySelector(`#${CSS.escape(id)}`);
const scroll = this.$.articleScroll();
if (!el || !scroll) return;
// Open any collapsed ancestors so the heading is visible (docs fold).
let node = el.parentElement;
while (node && node !== this.host) {
if (node.localName === "slate-collapse" && !node.hasAttribute("open")) {
if (typeof node.toggle === "function") node.toggle(true);
else node.setAttribute("open", "");
}
node = node.parentElement;
}
const top = el.offsetTop - 12;
scroll.scrollTo({ top, behavior: "smooth" });
if (pushHash) {
const { slug } = this.navigationService.readLocation();
history.pushState(null, "", `?${slug}#${id}`);
}
el.classList.remove("md-heading-flash");
void el.offsetWidth;
el.classList.add("md-heading-flash");
}
renderSearch(query) {
const panel = this.$.searchPanel();
if (!panel) return;
const q = String(query || "").trim();
if (!q) {
this.hideSearchPanel();
return;
}
const results = this.searchService.search(q);
panel.removeAttribute("hidden");
const specs = results.length
? results.map((r) => ({
tag: "border",
class: "docs-search-hit",
role: "option",
tabindex: "0",
"data-search-slug": r.slug,
children: [
{ tag: "textblock", class: "docs-search-hit-title", text: r.title },
{
tag: "textblock",
class: "docs-search-hit-meta",
text: r.location || r.type,
},
],
}))
: [
{
tag: "textblock",
class: "docs-search-hit-meta",
text: "No results",
},
];
fill(panel, specs);
panel.onclick = (e) => {
const hit = e.target.closest?.("[data-search-slug]");
if (!hit) return;
const slug = hit.getAttribute("data-search-slug");
this.hideSearchPanel();
const search = this.$.search();
if (search) search.setAttribute("text", "");
this.navigationService.navigate(slug);
};
}
hideSearchPanel() {
this.$.searchPanel()?.setAttribute("hidden", "");
}
focusSearch() {
const field = this.$.search();
field?.focus?.();
const sidebar = this.host.querySelector("#docs-viewer-sidebar");
if (sidebar?.getAttribute("layout") === "drawer" && !sidebar.hasAttribute("open")) {
sidebar.setAttribute("open", "");
}
}
setProgress(on) {
this.$.progress()?.toggleAttribute("hidden", !on);
}
showError(message) {
const el = this.$.error();
if (!el) return;
el.removeAttribute("hidden");
el.setAttribute("text", message);
const md = this.$.markdown();
if (md) {
md.__mdContent = null;
md.setAttribute("content", "");
}
}
clearError() {
const el = this.$.error();
if (!el) return;
el.setAttribute("hidden", "");
el.removeAttribute("text");
}
}
function findSlugByPath(documents, path) {
for (const doc of documents || []) {
if (doc.path === path) return doc.slug;
if (doc.type === "folder" && doc.items) {
const found = findSlugByPath(doc.items, path);
if (found) return found;
}
}
return "";
}
/** Map Font Awesome class strings to slate-icon names (FA provider). */
export function faClassToIconName(iconClass) {
const parts = String(iconClass || "")
.split(/\s+/)
.filter(Boolean);
const namePart = parts.find(
(p) =>
p.startsWith("fa-") &&
!["fa-solid", "fa-brands", "fa-regular", "fa-sharp"].includes(p) &&
p !== "fa"
);
const raw = (namePart || "link").replace(/^fa-/, "");
if (parts.some((p) => p.includes("brands") || p === "fab")) return `fab:${raw}`;
if (parts.some((p) => p.includes("regular") || p === "far")) return `far:${raw}`;
if (parts.some((p) => p.includes("solid") || p === "fas")) return `fas:${raw}`;
return raw;
}
function shortSocialLabel(title) {
const t = String(title || "").trim();
if (/github/i.test(t)) return "GitHub";
if (/youtube/i.test(t)) return "YouTube";
if (/steam/i.test(t)) return "Steam";
if (/discord/i.test(t)) return "Discord";
if (/bluesky|bsky/i.test(t)) return "Bluesky";
const head = t.split(/\s[-–—]\s/)[0]?.trim() || t;
return head.slice(0, 12) || "Link";
}

73
src/index.js Normal file
View File

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

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

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

View File

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

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

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

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

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

89
src/prose.css Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,415 @@
--- 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="bars" 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">
<horizontalbox
class="docs-viewer-topbar"
gap="10"
valign="center"
padding="0 12"
height="48"
>
<border
class="docs-viewer-topbar-menu"
sidebar="docs-viewer-sidebar"
padding="6 8"
role="button"
tabindex="0"
aria-label="Open navigation"
>
<slate-icon name="bars" size="16"></slate-icon>
</border>
<img class="docs-viewer-topbar-logo" data-docs-logo-top src="./img/logo.png" alt="" />
<textblock class="docs-viewer-topbar-divider" text="/"></textblock>
<textblock class="docs-viewer-topbar-title" data-docs-page-title text="Documentation" fill></textblock>
</horizontalbox>
<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>
</canvaspanel>
<slate-side-bar
class="docs-viewer-outline-rail"
width="240"
collapse-at="1000"
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-topbar {
flex: 0 0 auto; /* umc-layout-ok */
display: none; /* umc-layout-ok — mobile / drawer only */
background: var(--panel);
border-bottom: 1px solid var(--line);
box-sizing: border-box;
}
self:has(#docs-viewer-sidebar[layout="drawer"]) .docs-viewer-topbar {
display: flex; /* umc-layout-ok */
}
.docs-viewer-topbar-menu {
--widget-background: transparent;
--widget-border: none;
cursor: pointer;
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-viewer-topbar-logo {
display: block; /* umc-layout-ok */
height: 24px; /* umc-layout-ok — OG .brand-logo */
width: auto; /* umc-layout-ok */
object-fit: contain;
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-viewer-topbar-logo[hidden] {
display: none !important;
}
.docs-viewer-topbar-divider {
color: var(--muted);
font-size: 0.9rem;
flex: 0 0 auto; /* umc-layout-ok */
}
.docs-viewer-topbar-title {
font-size: 0.9rem;
min-width: 0; /* umc-layout-ok */
overflow: hidden; /* umc-layout-ok */
text-overflow: ellipsis;
white-space: nowrap;
}
.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);
}
/* Drop the outline with the nav drawer (same breakpoint) — no outline drawer. */
self:has(#docs-viewer-sidebar[layout="drawer"]) .docs-viewer-outline-rail,
.docs-viewer-outline-rail[layout="drawer"] {
display: none !important;
}
.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-topbar,
.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 imgs = el.querySelectorAll("[data-docs-logo], [data-docs-logo-top]");
const text = el.querySelector("[data-docs-site-name]");
for (const img of imgs) {
if (logoUrl) {
img.setAttribute("src", logoUrl);
img.removeAttribute("hidden");
} else {
img.setAttribute("hidden", "");
}
}
if (logoUrl) text?.setAttribute("hidden", "");
else text?.removeAttribute("hidden");
},
});
--- preview ---
<slate-docs-viewer index="./index.json"></slate-docs-viewer>

View File

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

View File

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

1128
styles.css

File diff suppressed because it is too large Load Diff

62
vite.config.js Normal file
View File

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