Add in-app documentation browsers

This commit is contained in:
2026-07-19 18:57:46 +02:00
parent e8764262a3
commit 7d20139531
14 changed files with 1569 additions and 26 deletions

498
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,498 @@
# RuDS User Guide
## In this article
- [Who this guide is for](#who-this-guide-is-for)
- [How RuDS works](#how-ruds-works)
- [Getting started](#getting-started)
- [Understanding the interface](#understanding-the-interface)
- [Working with posts](#working-with-posts)
- [Working with pages](#working-with-pages)
- [Working with media](#working-with-media)
- [Working with translations](#working-with-translations)
- [Using macros](#using-macros)
- [Using scripting](#using-scripting)
- [Using the AI assistant](#using-the-ai-assistant)
- [Organizing with tags](#organizing-with-tags)
- [Using blogmarks](#using-blogmarks)
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
- [Using Git (Source Control)](#using-git-source-control)
- [Configuring settings](#configuring-settings)
- [Checking and repairing metadata](#checking-and-repairing-metadata)
- [Managing templates](#managing-templates)
- [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows)
- [Working fully offline](#working-fully-offline)
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
- [Team conventions](#team-conventions)
## Who this guide is for
This guide is for people who use RuDS day to day to create, edit, organize, translate, generate, and publish blog content. It is written for editors, content managers, and project owners who need reliable guidance on what each part of the application does and how to use it safely.
If you need implementation notes, project architecture, or development setup, use the repository README. This guide stays focused on end-user operation and editorial decisions.
### Key takeaways
- RuDS documentation should help with real editorial work, not only isolated clicks.
- Each chapter explains purpose first, then usage.
- Safe content handling and recoverability matter throughout the application.
[↑ Back to In this article](#in-this-article)
---
## How RuDS works
RuDS is a local-first writing and publishing workspace. You can draft, revise, structure, preview, and publish content on your machine without depending on constant internet access. Optional remote Git synchronization and AI-assisted workflows extend that model, but they do not replace it.
Three states matter in day-to-day work. A draft is your in-progress state. Publishing marks a local content state as published inside your project. A Git commit creates a recoverable snapshot that can be reviewed, synchronized, and restored. These actions are related, but they are not the same operation.
The recommended sequence remains simple: edit in draft, publish when the content is ready, then commit immediately. That is the safest pattern for protecting work and keeping project history understandable.
### Key takeaways
- RuDS is designed for local reliability first.
- Publish and commit are different actions and both matter.
- The safe default lifecycle is: Draft -> Publish -> Commit.
[↑ Back to In this article](#in-this-article)
---
## Getting started
Before you begin editorial work, confirm that the project context is correct. Open RuDS and select the right project. If this is a new project, create it and define its identity early, including project name and description.
Next, open Settings and verify the project data path and Public Base URL. The data path should match your backup strategy. The Public Base URL should be set early because sitemap and feed generation depend on it.
Finally, define language and author defaults. These defaults reduce repetitive edits and keep output consistent when multiple contributors work in the same project.
### Key takeaways
- Set project identity, data location, and Public Base URL at the beginning.
- Configure language and author defaults before regular editing starts.
- Early setup decisions reduce later cleanup.
[↑ Back to In this article](#in-this-article)
---
## Understanding the interface
The RuDS interface is organized around workflows rather than isolated forms. The Activity Bar on the left moves between major areas such as Posts, Pages, Media, Tags, Import, Source Control, and Settings. The Sidebar changes with the active area and helps with filtering, selection, and navigation. The Editor area is where most work happens and supports tabbed editing for content, configuration, and analysis views.
The bottom panel and status area matter during longer operations such as imports, rebuild actions, metadata scans, and media work. Toasts provide quick feedback. The Output panel provides deeper detail when something needs attention.
Tab behavior is optimized for quick scanning and focused editing. Single click often opens a transient tab. Double click or explicit actions pin a tab for longer work.
### Key takeaways
- Use the Activity Bar for section-level context switching.
- Use the Sidebar for finding and narrowing content.
- Pin tabs when you move from inspection to editing.
[↑ Back to In this article](#in-this-article)
---
## Working with posts
The Posts section is for chronological content such as articles, notes, and recurring updates. In most editorial teams, Posts are the primary outward-facing stream.
A post combines title, body content, category, tags, excerpt, and status. Titles establish topic. Body content carries the narrative. Categories provide broad structure. Tags support finer discovery. Status should be used intentionally so collaborative workflows stay clear.
A reliable post workflow is: draft to completion, review structure and metadata, preview the result, publish when editorially ready, then commit immediately.
When you want help refining post metadata, use Quick Actions in the post editor and review AI suggestions for title, summary, and slug. Treat this as editorial assistance, not an automatic rewrite.
### Key takeaways
- Use Posts for date-oriented and regularly updated content.
- Categories and tags serve different purposes: broad grouping versus precise discovery.
- Publish only when editorially ready, then commit right away.
[↑ Back to In this article](#in-this-article)
---
## Working with pages
Pages are for durable, non-chronological content such as About, Contact, legal notices, and other structural information. Use Pages when content should stay stable in navigation and should not be interpreted as part of a time-based feed.
Because pages are revisited over longer periods, naming consistency matters. Keep titles and slugs predictable, avoid unnecessary structural churn, and follow your project navigation conventions.
The working pattern is similar to posts: draft, review, preview, publish, commit. The difference is editorial intent: pages prioritize clarity and long-term maintainability over release cadence.
### Key takeaways
- Use Pages for stable structural content.
- Keep titles and slugs consistent for maintainability.
- Apply the same safe lifecycle: Draft -> Publish -> Commit.
[↑ Back to In this article](#in-this-article)
---
## Working with media
The Media section is where you import, describe, and maintain assets used by posts and pages. It is not only a file list; it is also where accessibility and descriptive quality are enforced through metadata.
When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse.
You can also drag image files into the post editor or paste screenshots from the clipboard. RuDS imports the image into the media library, links it to the current post, and inserts the Markdown image at the cursor position.
### Key takeaways
- Media management includes metadata quality, not only file import.
- Add alt text and captions during import, not as a postponed task.
- Commit content and related media in the same change when possible.
[↑ Back to In this article](#in-this-article)
---
## Working with translations
RuDS supports translating both posts and media metadata into multiple languages. Translations are stored separately from canonical content so localized variants do not drift into unrelated records.
### Post translations
Each post has a canonical language and can have translations for additional languages. Translations keep their own title, excerpt, and content, while canonical metadata such as category, tags, slug, and publish state stays centralized.
The post editor shows the current language, existing translations, and missing languages. Posts marked Do Not Translate are excluded from automatic translation and from alternate language trees during site generation.
Published translation body content follows the same filesystem rule as published posts: the body lives in the file, not in the database.
### Media translations
Media items can have translated title, alt text, and caption values per language. The binary asset stays shared; only descriptive text varies by language.
### Automatic translation cascade
When blog languages are configured, RuDS can fill missing translations for posts and linked media. Automatic translation respects airplane mode and the configured AI runtime. If an automatic action cannot run in the current AI mode, RuDS reports that through the UI instead of silently inventing a result.
### Key takeaways
- Post translations store title, excerpt, and content separately from the canonical post.
- Media translations store translated descriptive text while the asset stays shared.
- Automatic translation keeps posts and linked media aligned across configured languages.
- Do Not Translate excludes content from multi-language workflows.
[↑ Back to In this article](#in-this-article)
---
## Using macros
Macros let you insert dynamic content blocks directly inside Markdown by using `[[macro_name ...]]` syntax. RuDS expands these macros during preview and generated output using local assets only.
Built-in macros include YouTube, Vimeo, gallery, photo archive, and tag cloud helpers. Use them when you want reusable rich blocks without dropping into raw HTML.
### Key takeaways
- Macros are inserted directly in Markdown and expanded during preview and publishing.
- Use macro parameters to control behavior without leaving the editor.
- Built-in macros remain the first choice for common embedded content blocks.
[↑ Back to In this article](#in-this-article)
---
## Using scripting
Scripts in RuDS are Lua files stored in your project's `scripts/` directory. Published scripts are written as `.lua` files with frontmatter metadata, so they stay portable and Git-reviewable.
Each script has a Kind (`macro`, `transform`, or `utility`) and an Entrypoint. Utility and transform scripts typically default to `main`. Macro scripts default to `render`.
### Transform scripts
Transform scripts run during blogmark import to normalize or enrich incoming post data before the post is created. The entrypoint receives a post table and can optionally receive a context table.
```lua
function main(post, context)
local title = (post.title or ""):gsub("^%s+", ""):gsub("%s+$", "")
if title ~= "" and not title:match("^%[Clipped%]") then
post.title = "[Clipped] " .. title
end
post.categories = { "Inbox", "Research" }
return post
end
```
`context.source` identifies the import source. `context.url` contains the original bookmarked URL when that information exists.
### Macro scripts
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and generation. The entrypoint receives a context table and the current post table.
```lua
function render(context, post)
local params = context.params or {}
local title = (post and post.title) or "Unknown"
local label = params.label or ""
return {
html = "<p>" .. title .. ": " .. label .. "</p>"
}
end
```
Built-in macros take priority over custom Lua macros that reuse the same slug.
### API access
Lua scripts can call the application API through `bds`. The in-app API tab is rendered from the live Lua capability map, and [API.md](API.md) is generated from the same source.
```lua
local result = bds.posts.get("post-id")
```
### Key takeaways
- Scripts in RuDS are Lua files, not Python files.
- Published scripts are stored as `.lua` files with frontmatter metadata.
- `main` is the usual entrypoint for utility and transform scripts; `render` is the usual entrypoint for macros.
- The scripting API is documented with Lua examples and kept in sync with the live runtime.
[↑ Back to In this article](#in-this-article)
---
## Using the AI assistant
The AI assistant is integrated into RuDS to help with editorial tasks such as search, analysis, metadata suggestions, translation, and structured content inspection.
The assistant works on your project data. Depending on configuration, requests can run against the configured online endpoint or the airplane-mode endpoint. Automatic AI actions remain gated by airplane mode rules in the app, and RuDS surfaces status through toasts and the Output area instead of silently bypassing that policy.
The assistant can present results as text, tables, cards, charts, metrics, lists, forms, and tabbed views. Ask plainly for the result you need, or request a specific presentation when that helps your workflow.
### Key takeaways
- The assistant works with your project content and metadata.
- AI configuration can be online or airplane-mode based, depending on your setup.
- Automatic AI actions respect airplane mode and report availability through the UI.
- Ask for a table, chart, list, or form when a specific shape is useful.
[↑ Back to In this article](#in-this-article)
---
## Organizing with tags
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer help readers or editors. Use the Tags area to keep taxonomy useful.
After significant taxonomy cleanup, create a focused commit that captures the change clearly.
### Key takeaways
- Tags improve discovery only if naming stays consistent.
- Merge and rename operations should be deliberate and reviewed.
- Commit taxonomy changes in focused snapshots.
[↑ Back to In this article](#in-this-article)
---
## Using blogmarks
Blogmarks provide a quick way to save links from the browser directly into RuDS as new posts. Generate the bookmarklet from Settings, add it to your browser bar, and click it when you want to capture a page into the current project.
Transform scripts can normalize incoming blogmark posts before creation. Use them for title cleanup, default tags, or source-specific formatting.
### Key takeaways
- Blogmarks turn the browser into a one-click content capture tool.
- Generate the bookmarklet from Settings and add it to your browser bar.
- Use transform scripts to enrich incoming posts automatically.
[↑ Back to In this article](#in-this-article)
---
## Importing from WordPress (WXR)
The Import section supports structured migration from WordPress exports. Treat import as a staged workflow: analyze first, adjust mappings, then execute. For larger sites, iterative passes are usually safer than a single rigid import.
### Key takeaways
- Treat WXR import as analyze, adjust, execute.
- Iterative passes are safer than one large import.
- Validate representative output before committing migrated content.
[↑ Back to In this article](#in-this-article)
---
## Using Git (Source Control)
Source Control in RuDS is the foundation for reliable recovery and collaboration. Publishing marks local editorial state, but Git commits provide durable history.
In a normal cycle, synchronize first, complete editorial changes, publish when ready, commit with a specific message, then push when you want to share the result.
### Key takeaways
- Git provides recoverable history; publishing alone does not.
- A stable rhythm is: sync, edit, publish, commit, push.
- Specific commit messages improve teamwork and recovery.
[↑ Back to In this article](#in-this-article)
---
## Configuring settings
Settings define how the project behaves. Project settings control identity, paths, public URL context, and render languages. Editor settings shape day-to-day working defaults. AI settings are optional and should enhance, not define, your editorial workflow.
Maintenance actions such as rebuilds and diff scans are repair tools for specific situations, not part of routine editing.
### Key takeaways
- Settings affect long-term consistency across the project.
- Optional integrations should not replace the core workflow.
- Rebuild actions are corrective tools, not daily habits.
[↑ Back to In this article](#in-this-article)
---
## Checking and repairing metadata
Over time, metadata stored in the database and metadata stored on disk can drift apart, especially after external edits, merges, or file operations. The Metadata Diff tool detects these inconsistencies and lets you repair them without rebuilding everything.
The scan covers posts, media, scripts, and templates. Results are grouped by entity type, and field pills let you focus on one kind of difference at a time.
Use DB to File when the database is correct. Use File to DB when the filesystem is correct.
### Key takeaways
- Metadata Diff compares database records against files on disk.
- Field pills help you bulk-repair one difference type at a time.
- Use it after external changes, not as part of routine editing.
[↑ Back to In this article](#in-this-article)
---
## Managing templates
Templates control the Liquid layout used when RuDS generates HTML pages. Template kinds determine where they are used: `post`, `list`, `not-found`, and `partial`.
Templates are stored as files with frontmatter metadata in the project data directory, so they are portable and Git-reviewable.
### Key takeaways
- Templates define the generated HTML layout.
- Four template kinds cover page, list, not-found, and reusable partial rendering.
- Templates are filesystem-backed and Git-friendly.
[↑ Back to In this article](#in-this-article)
---
## Generating and publishing
Publishing in RuDS is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready.
Full generation builds the entire static site. Site validation detects missing, extra, and updated routes so RuDS can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
### Key takeaways
- Full generation produces the complete site.
- Validate and Apply is the efficient daily workflow for incremental publishing.
- Public Base URL must be set before generation.
- Commit generated output before deploying for recoverability.
[↑ Back to In this article](#in-this-article)
---
## Typical editorial workflows
Short link posts benefit from a lightweight workflow: create, add concise context, classify, preview once, publish, commit. Long-form articles benefit from a fuller cycle: draft thoroughly, add media, review metadata, preview carefully, publish, commit content and media together.
Across both patterns, the safety baseline stays the same: Draft -> Publish -> Commit.
### Key takeaways
- Use a lightweight workflow for short notes and links.
- Use a fuller workflow for long-form content with media.
- Keep the same safety baseline in both cases.
[↑ Back to In this article](#in-this-article)
---
## Working fully offline
RuDS is designed so core editorial work can continue without network access. You can create and revise content, manage metadata, preview locally, and publish within local project state while offline.
When AI is involved, airplane mode determines which automatic actions are allowed and which endpoint class is used. Keep local commits frequent even when you are not pushing to a remote.
### Key takeaways
- Core editing and publishing workflows work offline.
- Local commits still matter when no remote is available.
- Reconnect and synchronize in a controlled order.
[↑ Back to In this article](#in-this-article)
---
## Running a headless server and the terminal UI
RuDS can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/bds/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
### Key takeaways
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
- All connected clients stay synchronized and share one interface language.
[↑ Back to In this article](#in-this-article)
---
## Troubleshooting and recovery
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
If content lists or references become inconsistent after manual file changes, start with Metadata Diff. If broader inconsistency remains, use rebuild tools to realign database and filesystem state.
### Key takeaways
- Most missing remote content issues are commit or push gaps.
- Metadata Diff is the first repair tool after external file changes.
- Frequent meaningful commits are the strongest safety net.
[↑ Back to In this article](#in-this-article)
---
## Team conventions
Shared conventions reduce ambiguity and merge friction. Teams should agree on category definitions, tag naming rules, publish-readiness criteria, and commit message patterns.
A practical minimum rule is simple: any content considered published should be committed promptly.
### Key takeaways
- Explicit conventions improve speed and reduce avoidable conflict.
- Start with a small rule set and enforce it consistently.
- Minimum standard: published content should be committed promptly.
[↑ Back to In this article](#in-this-article)

View File

@@ -13,6 +13,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Template and Lua script management with explicit syntax-check feedback, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms.
- SQLite and filesystem persistence with frontmatter, sidecars, rebuild, metadata diff/repair, and FTS5 search.
- Optional on-device multilingual semantic search and tag suggestions backed by a persistent USearch index, with duplicate-post review and dismissal in the desktop workspace.
- Read-only in-app browsers for the bundled global `DOCUMENTATION.md` and the generated Lua API reference, public types, and runnable examples, with safe GFM rendering and confirmed external links.
- Project-scoped typed domain events synchronize desktop views with shared-engine and future CLI mutations; persisted CLI notifications are consumed once, and the selected UI language is shared through settings.
- Headless `bds-cli` automation for rebuild/repair/render, publishing and Git sync, post/media/gallery creation, shared settings/projects, utility Lua tasks, JSON I/O, airplane-mode AI routing, and guarded launcher installation from Settings → Data or `bds-cli install`.
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.

View File

@@ -50,19 +50,15 @@ Done:
- Localized duplicate review with exact-match detection, 500-pair pagination, canonical single/batch dismissal, and post navigation.
- Embedding-aware filesystem rebuild and metadata diff/repair, settings gating, project-switch/shutdown flushes, and native menu commands.
### Translation QA and documentation UX — Partly done
### Translation QA and documentation UX — Done
Done:
- Translation validation engine and report view.
Open:
- In-app project documentation browser.
- Browsable Lua API documentation and examples.
- Replace Documentation and API Documentation placeholders.
The generated Lua documentation and examples are complete core functionality; this section tracks only their in-app browsing experience.
- Bundled global `DOCUMENTATION.md` browser with localized loading, empty, missing, and malformed states plus explicit and file-change refresh.
- Generated Lua API, type, and executable-example browser sourced directly from `docs/scripting/` with embedded packaged fallbacks.
- Safe native GFM rendering for headings, links, code blocks, lists, and tables, including in-document anchors and confirmed external links without HTML, CSS, or JavaScript execution.
- Project switches preserve the singleton global documentation tabs and their loaded content.
### Menu editor and deep links — Partly done

View File

@@ -33,6 +33,7 @@ use crate::views::{
DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag,
DashboardTimelineMonth,
},
documentation::{DocumentLoad, DocumentationKind, DocumentationState, current_signature},
duplicates::DuplicatesState,
git::{GitDiffLoad, GitDiffState, GitNetworkCompletion, GitSnapshot, GitUiState},
import_editor::{
@@ -252,6 +253,9 @@ pub enum Message {
DuplicatesDismissed(Result<(), String>),
DuplicatesShowMore,
DuplicatesOpenPost(String),
DocumentationRefresh(DocumentationKind),
DocumentationLoaded(DocumentationKind, u64, DocumentLoad),
DocumentationLinkClicked(DocumentationKind, String),
EmbeddingReindex,
EmbeddingBackfill,
LoadSemanticTagSuggestions(String),
@@ -946,6 +950,8 @@ pub struct BdsApp {
dashboard_state: Option<DashboardState>,
site_validation_state: SiteValidationState,
duplicates_state: DuplicatesState,
guide_documentation: DocumentationState,
api_documentation: DocumentationState,
metadata_diff_state: MetadataDiffState,
translation_validation_state: crate::views::translation_validation::TranslationValidationState,
git_state: GitUiState,
@@ -1111,6 +1117,8 @@ impl BdsApp {
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
guide_documentation: DocumentationState::new(DocumentationKind::Guide),
api_documentation: DocumentationState::new(DocumentationKind::Api),
metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
@@ -1190,6 +1198,8 @@ impl BdsApp {
dashboard_state: None,
site_validation_state: SiteValidationState::default(),
duplicates_state: DuplicatesState::default(),
guide_documentation: DocumentationState::new(DocumentationKind::Guide),
api_documentation: DocumentationState::new(DocumentationKind::Api),
metadata_diff_state: MetadataDiffState::default(),
translation_validation_state: Default::default(),
git_state: GitUiState::default(),
@@ -1601,8 +1611,13 @@ impl BdsApp {
}
}
let sidebar_task = self.refresh_counts();
let documentation_task = self.reload_changed_documentation();
self.sync_menu_state();
Task::batch([sidebar_task, Task::done(Message::EmbeddingBackfill)])
Task::batch([
sidebar_task,
Task::done(Message::EmbeddingBackfill),
documentation_task,
])
}
Message::SwitchProject(project_id) => {
self.project_dropdown_open = false;
@@ -2238,6 +2253,38 @@ impl BdsApp {
is_dirty: false,
}))
}
Message::DocumentationRefresh(kind) => self.start_documentation_load(kind),
Message::DocumentationLoaded(kind, generation, load) => {
let state = self.documentation_state_mut(kind);
if state.load_generation == generation {
state.apply(load);
}
Task::none()
}
Message::DocumentationLinkClicked(kind, url) => {
if url == crate::views::documentation::API_DOCUMENTATION_URL {
self.open_singleton_tab(TabType::ApiDocumentation, "tabBar.apiDocumentation");
return self.start_documentation_load(DocumentationKind::Api);
} else if let Some(anchor) = url.strip_prefix("https://ruds.invalid/document#") {
if let Some(offset) = self.documentation_state(kind).parsed.anchors.get(anchor)
{
return iced::widget::scrollable::snap_to(
crate::views::documentation::scroll_id(kind),
iced::widget::scrollable::RelativeOffset { x: 0.0, y: *offset },
);
}
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "documentation.anchorMissing"),
);
} else if !self.confirm_external_link(url) {
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "documentation.linkRefused"),
);
}
Task::none()
}
Message::EmbeddingReindex => self.start_embedding_reindex(),
Message::EmbeddingBackfill => self.start_embedding_backfill(),
Message::LoadSemanticTagSuggestions(post_id) => {
@@ -2286,11 +2333,12 @@ impl BdsApp {
self.auto_save_due_post_editors();
}
let actions = std::mem::take(&mut *self.script_menu_actions.lock().unwrap());
Task::batch(
actions
.into_iter()
.map(|action| self.dispatch_menu_action(action)),
)
let mut tasks = actions
.into_iter()
.map(|action| self.dispatch_menu_action(action))
.collect::<Vec<_>>();
tasks.push(self.reload_changed_documentation());
Task::batch(tasks)
}
Message::DomainEventsTick => self.process_domain_events(),
Message::CancelTask(task_id) => {
@@ -2603,6 +2651,14 @@ impl BdsApp {
self.active_modal = None;
match action {
modal::ConfirmAction::RebuildSearchIndex => self.start_search_index_rebuild(),
modal::ConfirmAction::OpenExternalUrl(url) => {
if (url.starts_with("https://") || url.starts_with("http://"))
&& let Err(error) = open::that_detached(&url)
{
self.notify(ToastLevel::Error, &error.to_string());
}
Task::none()
}
modal::ConfirmAction::DeleteProject(id) => {
Task::done(Message::DeleteProject(id))
}
@@ -2799,6 +2855,83 @@ impl BdsApp {
}
}
fn documentation_state(&self, kind: DocumentationKind) -> &DocumentationState {
match kind {
DocumentationKind::Guide => &self.guide_documentation,
DocumentationKind::Api => &self.api_documentation,
}
}
fn documentation_state_mut(&mut self, kind: DocumentationKind) -> &mut DocumentationState {
match kind {
DocumentationKind::Guide => &mut self.guide_documentation,
DocumentationKind::Api => &mut self.api_documentation,
}
}
fn start_documentation_load(&mut self, kind: DocumentationKind) -> Task<Message> {
let generation = self.documentation_state_mut(kind).start_loading();
Task::perform(
async move {
tokio::task::spawn_blocking(move || match kind {
DocumentationKind::Guide => crate::views::documentation::load_user_guide(),
DocumentationKind::Api => crate::views::documentation::load_api_document(),
})
.await
.unwrap_or_else(|error| DocumentLoad::Malformed {
signature: 0,
error: format!("documentation task panicked: {error}"),
})
},
move |load| Message::DocumentationLoaded(kind, generation, load),
)
}
fn reload_changed_documentation(&mut self) -> Task<Message> {
let mut changed = Vec::new();
for (tab_type, kind) in [
(TabType::Documentation, DocumentationKind::Guide),
(TabType::ApiDocumentation, DocumentationKind::Api),
] {
if !self.tabs.iter().any(|tab| tab.tab_type == tab_type) {
continue;
}
let should_check = self.documentation_state(kind).should_check();
if !should_check {
continue;
}
let signature = current_signature(kind);
let state = self.documentation_state_mut(kind);
state.mark_checked();
if state.status == crate::views::documentation::DocumentStatus::NotLoaded
|| signature != state.signature
{
changed.push(kind);
}
}
Task::batch(
changed
.into_iter()
.map(|kind| self.start_documentation_load(kind)),
)
}
fn confirm_external_link(&mut self, url: String) -> bool {
if !url.starts_with("https://") && !url.starts_with("http://") {
return false;
}
self.active_modal = Some(modal::ModalState::Confirm {
title: t(self.ui_locale, "documentation.externalLinkTitle"),
message: tw(
self.ui_locale,
"documentation.externalLinkMessage",
&[("url", &url)],
),
on_confirm: modal::ConfirmAction::OpenExternalUrl(url),
});
true
}
fn start_duplicate_search(&mut self, page: usize) -> Task<Message> {
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
return Task::none();
@@ -3005,6 +3138,8 @@ impl BdsApp {
self.dashboard_state.as_ref(),
&self.site_validation_state,
&self.duplicates_state,
&self.guide_documentation,
&self.api_documentation,
&self.metadata_diff_state,
&self.translation_validation_state,
&self.git_state,
@@ -3818,7 +3953,11 @@ impl BdsApp {
}
MenuAction::OpenDocumentation => {
self.open_singleton_tab(TabType::Documentation, "tabBar.documentation");
Task::none()
self.start_documentation_load(DocumentationKind::Guide)
}
MenuAction::OpenApiDocumentation => {
self.open_singleton_tab(TabType::ApiDocumentation, "tabBar.apiDocumentation");
self.start_documentation_load(DocumentationKind::Api)
}
MenuAction::ViewOnGitHub => {
let _ = open::that("https://github.com/nickarumern/bds");
@@ -8293,6 +8432,7 @@ mod tests {
use crate::state::sidebar_filter::{MediaFilter, PostFilter};
use crate::state::tabs::{Tab, TabType};
use crate::views::chat_view::ChatEditorState;
use crate::views::documentation::{DocumentLoad, DocumentationKind};
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::modal;
use crate::views::post_editor::{PostEditorMsg, PostEditorState};
@@ -8360,6 +8500,128 @@ mod tests {
(db, project, tempdir)
}
#[test]
fn documentation_external_links_require_confirmation_and_api_help_opens_real_tab() {
let (db, project, temp) = setup();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
let _ = app.update(Message::DocumentationLinkClicked(
DocumentationKind::Guide,
"https://example.com/guide".to_string(),
));
assert!(matches!(
app.active_modal,
Some(modal::ModalState::Confirm {
on_confirm: modal::ConfirmAction::OpenExternalUrl(ref url),
..
}) if url == "https://example.com/guide"
));
app.active_modal = None;
let _ = app.update(Message::DocumentationLinkClicked(
DocumentationKind::Guide,
crate::views::documentation::API_DOCUMENTATION_URL.to_string(),
));
assert!(
app.tabs
.iter()
.any(|tab| tab.tab_type == TabType::ApiDocumentation)
);
assert_eq!(
app.api_documentation.status,
crate::views::documentation::DocumentStatus::Loading
);
}
#[test]
fn project_switch_keeps_global_documentation_loaded() {
let (db, project, temp) = setup();
let second_dir = temp.path().join("second-project");
std::fs::create_dir_all(second_dir.join("meta")).unwrap();
for name in [
"project.json",
"publishing.json",
"categories.json",
"category-meta.json",
] {
std::fs::copy(
temp.path().join("meta").join(name),
second_dir.join("meta").join(name),
)
.unwrap();
}
let second = Project {
id: "p2".to_string(),
name: "Second Project".to_string(),
slug: "second-project".to_string(),
data_path: Some(second_dir.to_string_lossy().into_owned()),
is_active: false,
..project.clone()
};
insert_project(db.conn(), &second).unwrap();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
app.projects.push(second.clone());
app.tabs.push(Tab {
id: "documentation".to_string(),
tab_type: TabType::Documentation,
title: "Documentation".to_string(),
is_transient: false,
is_dirty: false,
});
app.guide_documentation.apply(DocumentLoad::Ready {
source: "# Global guide".to_string(),
signature: 42,
});
let _ = app.update(Message::SwitchProject(second.id.clone()));
assert_eq!(
app.active_project.as_ref().map(|item| item.id.as_str()),
Some("p2")
);
assert_eq!(app.data_dir.as_deref(), Some(second_dir.as_path()));
assert_eq!(
app.guide_documentation.status,
crate::views::documentation::DocumentStatus::Ready
);
assert_eq!(app.guide_documentation.signature, 42);
}
#[test]
fn stale_documentation_load_cannot_overwrite_a_newer_guide_read() {
let (db, project, temp) = setup();
let mut app = BdsApp::new_for_tests(db, project, temp.path().to_path_buf());
let stale = app.guide_documentation.start_loading();
let current = app.guide_documentation.start_loading();
let _ = app.update(Message::DocumentationLoaded(
DocumentationKind::Guide,
stale,
DocumentLoad::Ready {
source: "# Old guide".to_string(),
signature: 1,
},
));
assert_eq!(
app.guide_documentation.status,
crate::views::documentation::DocumentStatus::Loading
);
let _ = app.update(Message::DocumentationLoaded(
DocumentationKind::Guide,
current,
DocumentLoad::Ready {
source: "# New guide".to_string(),
signature: 2,
},
));
assert_eq!(
app.guide_documentation.status,
crate::views::documentation::DocumentStatus::Ready
);
assert_eq!(app.guide_documentation.signature, 2);
}
fn spawn_models_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();

View File

@@ -47,6 +47,7 @@ pub enum MenuAction {
// Help
About,
OpenDocumentation,
OpenApiDocumentation,
ViewOnGitHub,
ReportIssue,
}
@@ -82,6 +83,7 @@ impl MenuAction {
MenuAction::UploadSite,
MenuAction::About,
MenuAction::OpenDocumentation,
MenuAction::OpenApiDocumentation,
MenuAction::ViewOnGitHub,
MenuAction::ReportIssue,
];
@@ -116,6 +118,7 @@ impl MenuAction {
"upload_site" => Self::UploadSite,
"about" => Self::About,
"open_documentation" => Self::OpenDocumentation,
"open_api_documentation" => Self::OpenApiDocumentation,
"view_on_github" => Self::ViewOnGitHub,
"report_issue" => Self::ReportIssue,
_ => return None,
@@ -153,6 +156,7 @@ impl MenuAction {
Self::UploadSite => "menu.item.uploadSite",
Self::About => "menu.item.about",
Self::OpenDocumentation => "menu.item.openDocumentation",
Self::OpenApiDocumentation => "menu.item.openApiDocumentation",
Self::ViewOnGitHub => "menu.item.viewOnGitHub",
Self::ReportIssue => "menu.item.reportIssue",
}
@@ -483,6 +487,12 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = help_menu.append(&item(&mut reg, MenuAction::About, locale, None));
let _ = help_menu.append(&PredefinedMenuItem::separator());
let _ = help_menu.append(&item(&mut reg, MenuAction::OpenDocumentation, locale, None));
let _ = help_menu.append(&item(
&mut reg,
MenuAction::OpenApiDocumentation,
locale,
None,
));
let _ = help_menu.append(&item(&mut reg, MenuAction::ViewOnGitHub, locale, None));
let _ = help_menu.append(&item(&mut reg, MenuAction::ReportIssue, locale, None));

View File

@@ -0,0 +1,671 @@
use std::collections::{HashMap, hash_map::DefaultHasher};
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, UNIX_EPOCH};
use bds_core::i18n::UiLocale;
use iced::widget::{button, column, container, markdown, row, scrollable, text};
use iced::{Element, Length};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
const API_REFERENCE: &str = include_str!("../../../../docs/scripting/API_REFERENCE.md");
const API_TYPES: &str = include_str!("../../../../docs/scripting/TYPES.md");
const USER_GUIDE: &str = include_str!("../../../../DOCUMENTATION.md");
const UTILITY_EXAMPLE: &str = include_str!("../../../../docs/scripting/examples/utility.lua");
const MACRO_EXAMPLE: &str = include_str!("../../../../docs/scripting/examples/macro.lua");
const TRANSFORM_EXAMPLE: &str = include_str!("../../../../docs/scripting/examples/transform.lua");
pub const API_DOCUMENTATION_URL: &str = "https://ruds.invalid/api-documentation";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentationKind {
Guide,
Api,
}
#[derive(Debug, Clone)]
pub enum DocumentLoad {
Ready { source: String, signature: u64 },
Missing { signature: u64 },
Malformed { signature: u64, error: String },
}
impl DocumentLoad {
pub fn signature(&self) -> u64 {
match self {
Self::Ready { signature, .. }
| Self::Missing { signature }
| Self::Malformed { signature, .. } => *signature,
}
}
}
#[derive(Debug, Clone)]
pub enum DocumentBlock {
Markdown(Vec<markdown::Item>),
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
}
#[derive(Debug, Clone, Default)]
pub struct ParsedDocument {
pub blocks: Vec<DocumentBlock>,
pub anchors: HashMap<String, f32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocumentStatus {
NotLoaded,
Loading,
Ready,
Missing,
Malformed,
}
#[derive(Debug, Clone)]
pub struct DocumentationState {
pub kind: DocumentationKind,
pub status: DocumentStatus,
pub parsed: ParsedDocument,
pub signature: u64,
pub error: Option<String>,
pub load_generation: u64,
last_checked: Option<Instant>,
}
impl DocumentationState {
pub fn new(kind: DocumentationKind) -> Self {
Self {
kind,
status: DocumentStatus::NotLoaded,
parsed: ParsedDocument::default(),
signature: 0,
error: None,
load_generation: 0,
last_checked: None,
}
}
pub fn start_loading(&mut self) -> u64 {
self.load_generation = self.load_generation.saturating_add(1);
self.status = DocumentStatus::Loading;
self.error = None;
self.load_generation
}
pub fn apply(&mut self, load: DocumentLoad) {
self.signature = load.signature();
self.last_checked = Some(Instant::now());
match load {
DocumentLoad::Ready { source, .. } => {
self.parsed = parse_document(&source);
self.status = DocumentStatus::Ready;
self.error = None;
}
DocumentLoad::Missing { .. } => {
self.parsed = ParsedDocument::default();
self.status = DocumentStatus::Missing;
self.error = None;
}
DocumentLoad::Malformed { error, .. } => {
self.parsed = ParsedDocument::default();
self.status = DocumentStatus::Malformed;
self.error = Some(error);
}
}
}
pub fn should_check(&self) -> bool {
self.status != DocumentStatus::Loading
&& self
.last_checked
.is_none_or(|checked| checked.elapsed() >= Duration::from_secs(1))
}
pub fn mark_checked(&mut self) {
self.last_checked = Some(Instant::now());
}
}
pub fn scroll_id(kind: DocumentationKind) -> scrollable::Id {
scrollable::Id::new(match kind {
DocumentationKind::Guide => "user-documentation",
DocumentationKind::Api => "api-documentation",
})
}
pub fn view(state: &DocumentationState, locale: UiLocale) -> Element<'_, Message> {
let title_key = match state.kind {
DocumentationKind::Guide => "documentation.title",
DocumentationKind::Api => "documentation.apiTitle",
};
let subtitle_key = match state.kind {
DocumentationKind::Guide => "documentation.subtitle",
DocumentationKind::Api => "documentation.apiSubtitle",
};
let toolbar = inputs::toolbar(
vec![
column![
text(t(locale, title_key)).size(20),
text(t(locale, subtitle_key))
.size(12)
.color(inputs::LABEL_COLOR)
]
.spacing(3)
.into(),
],
vec![
button(text(t(locale, "common.refresh")).size(13))
.on_press(Message::DocumentationRefresh(state.kind))
.padding([6, 16])
.style(inputs::secondary_button)
.into(),
],
);
let body: Element<'_, Message> = match state.status {
DocumentStatus::NotLoaded | DocumentStatus::Loading => {
status_card(locale, "documentation.loading", inputs::LABEL_COLOR)
}
DocumentStatus::Missing => {
status_card(locale, "documentation.missing", inputs::LABEL_COLOR)
}
DocumentStatus::Malformed => status_card(
locale,
"documentation.malformed",
iced::Color::from_rgb(0.90, 0.38, 0.38),
),
DocumentStatus::Ready if state.parsed.blocks.is_empty() => {
status_card(locale, "documentation.empty", inputs::LABEL_COLOR)
}
DocumentStatus::Ready => {
let mut content = column![].spacing(12).width(Length::Fill);
for block in &state.parsed.blocks {
content = content.push(match block {
DocumentBlock::Markdown(items) => markdown::view(
items,
markdown::Settings::with_text_size(14),
markdown::Style::from_palette(inputs::app_theme().palette()),
)
.map({
let kind = state.kind;
move |url| Message::DocumentationLinkClicked(kind, url.to_string())
}),
DocumentBlock::Table { headers, rows } => table_view(headers, rows),
});
}
scrollable(container(content).padding(2))
.id(scroll_id(state.kind))
.height(Length::Fill)
.into()
}
};
container(column![toolbar, body].spacing(12))
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn status_card<'a>(locale: UiLocale, key: &str, color: iced::Color) -> Element<'a, Message> {
inputs::card(text(t(locale, key)).size(14).color(color)).into()
}
fn table_view<'a>(headers: &'a [String], rows: &'a [Vec<String>]) -> Element<'a, Message> {
let mut table = column![table_row(headers, true)].spacing(5);
for values in rows {
table = table.push(table_row(values, false));
}
inputs::card(table).into()
}
fn table_row<'a>(values: &'a [String], header: bool) -> Element<'a, Message> {
let mut cells = row![].spacing(8).width(Length::Fill);
for value in values {
let color = if header {
inputs::SECTION_COLOR
} else {
inputs::LABEL_COLOR
};
cells = cells.push(
container(text(value).size(if header { 12 } else { 11 }).color(color))
.width(Length::FillPortion(1)),
);
}
cells.into()
}
pub fn load_user_guide() -> DocumentLoad {
let path = user_guide_path();
match load_document_file(&path) {
DocumentLoad::Missing { signature } => DocumentLoad::Ready {
source: USER_GUIDE.to_string(),
signature,
},
load => load,
}
}
fn load_document_file(path: &Path) -> DocumentLoad {
let signature = file_signature(path);
match fs::read_to_string(path) {
Ok(source) => DocumentLoad::Ready { source, signature },
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
DocumentLoad::Missing { signature }
}
Err(error) => DocumentLoad::Malformed {
signature,
error: error.to_string(),
},
}
}
pub fn load_api_document() -> DocumentLoad {
let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/scripting");
let sources = [
(source_root.join("API_REFERENCE.md"), API_REFERENCE),
(source_root.join("TYPES.md"), API_TYPES),
(source_root.join("examples/utility.lua"), UTILITY_EXAMPLE),
(source_root.join("examples/macro.lua"), MACRO_EXAMPLE),
(
source_root.join("examples/transform.lua"),
TRANSFORM_EXAMPLE,
),
];
let signature = paths_signature(sources.iter().map(|(path, _)| path));
let mut loaded = Vec::with_capacity(sources.len());
for (path, embedded) in sources {
match fs::read_to_string(&path) {
Ok(source) => loaded.push(source),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
loaded.push(embedded.to_string());
}
Err(error) => {
return DocumentLoad::Malformed {
signature,
error: error.to_string(),
};
}
}
}
let [reference, types, utility, macro_example, transform] = loaded
.try_into()
.expect("five generated documentation sources");
let source = format!(
"{reference}\n\n{types}\n\n# Lua Examples\n\n## Utility Lua example\n\n```lua\n{utility}\n```\n\n## Macro Lua example\n\n```lua\n{macro_example}\n```\n\n## Transform Lua example\n\n```lua\n{transform}\n```\n"
);
DocumentLoad::Ready { source, signature }
}
pub fn current_signature(kind: DocumentationKind) -> u64 {
match kind {
DocumentationKind::Guide => file_signature(&user_guide_path()),
DocumentationKind::Api => {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/scripting");
paths_signature(
[
"API_REFERENCE.md",
"TYPES.md",
"examples/utility.lua",
"examples/macro.lua",
"examples/transform.lua",
]
.map(|relative| root.join(relative)),
)
}
}
}
fn user_guide_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../DOCUMENTATION.md")
}
pub fn parse_document(source: &str) -> ParsedDocument {
let safe = rewrite_links(&external_images_as_links(source));
let anchors = heading_anchors(&safe);
let lines = safe.lines().collect::<Vec<_>>();
let mut blocks = Vec::new();
let mut markdown_lines = Vec::new();
let mut index = 0;
let mut in_fence = false;
while index < lines.len() {
if lines[index].trim_start().starts_with("```") {
in_fence = !in_fence;
markdown_lines.push(lines[index]);
index += 1;
} else if !in_fence && let Some((headers, rows, consumed)) = parse_table(&lines[index..]) {
push_markdown(&mut blocks, &mut markdown_lines);
blocks.push(DocumentBlock::Table { headers, rows });
index += consumed;
} else {
markdown_lines.push(lines[index]);
index += 1;
}
}
push_markdown(&mut blocks, &mut markdown_lines);
ParsedDocument { blocks, anchors }
}
fn push_markdown(blocks: &mut Vec<DocumentBlock>, lines: &mut Vec<&str>) {
if lines.is_empty() {
return;
}
let items = markdown::parse(&lines.join("\n")).collect::<Vec<_>>();
if !items.is_empty() {
blocks.push(DocumentBlock::Markdown(items));
}
lines.clear();
}
fn parse_table(lines: &[&str]) -> Option<(Vec<String>, Vec<Vec<String>>, usize)> {
if lines.len() < 2 || !lines[0].contains('|') || !is_table_delimiter(lines[1]) {
return None;
}
let headers = table_cells(lines[0]);
if headers.is_empty() || table_cells(lines[1]).len() != headers.len() {
return None;
}
let mut rows = Vec::new();
let mut consumed = 2;
while consumed < lines.len() && lines[consumed].contains('|') {
let cells = table_cells(lines[consumed]);
if cells.len() != headers.len() {
break;
}
rows.push(cells);
consumed += 1;
}
Some((headers, rows, consumed))
}
fn is_table_delimiter(line: &str) -> bool {
let cells = table_cells(line);
!cells.is_empty()
&& cells.iter().all(|cell| {
let trimmed = cell.trim_matches(':');
trimmed.len() >= 3 && trimmed.chars().all(|character| character == '-')
})
}
fn table_cells(line: &str) -> Vec<String> {
let mut cells = Vec::new();
let mut cell = String::new();
let mut in_code = false;
let mut escaped = false;
for character in line.trim().chars() {
if escaped {
cell.push(character);
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '`' {
in_code = !in_code;
} else if character == '|' && !in_code {
cells.push(clean_table_cell(&cell));
cell.clear();
} else {
cell.push(character);
}
}
cells.push(clean_table_cell(&cell));
if cells.first().is_some_and(String::is_empty) {
cells.remove(0);
}
if cells.last().is_some_and(String::is_empty) {
cells.pop();
}
cells
}
fn clean_table_cell(cell: &str) -> String {
cell.trim().replace("**", "").replace("__", "")
}
fn external_images_as_links(source: &str) -> String {
let pattern = regex::Regex::new(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
.expect("external image Markdown regex");
pattern.replace_all(source, "[🖼 $1]($2)").into_owned()
}
fn rewrite_links(source: &str) -> String {
let pattern =
regex::Regex::new(r"(\[[^\]]*\]\()([^\s)]+)([^)]*\))").expect("Markdown link regex");
pattern
.replace_all(source, |captures: &regex::Captures<'_>| {
let target = &captures[2];
if target == "API.md" {
return format!("{}{}{}", &captures[1], API_DOCUMENTATION_URL, &captures[3]);
}
let Some(anchor) = internal_anchor(target) else {
return captures[0].to_string();
};
format!(
"{}https://ruds.invalid/document#{}{}",
&captures[1], anchor, &captures[3]
)
})
.into_owned()
}
fn internal_anchor(target: &str) -> Option<String> {
if let Some(anchor) = target.strip_prefix('#') {
return Some(anchor.to_string());
}
let (path, fragment) = target.split_once('#').unwrap_or((target, ""));
let default = match path {
"API_REFERENCE.md" => "ruds-lua-api-reference",
"TYPES.md" => "lua-api-types",
"examples" | "examples/" => "lua-examples",
"examples/utility.lua" => "utility-lua-example",
"examples/macro.lua" => "macro-lua-example",
"examples/transform.lua" => "transform-lua-example",
_ => return None,
};
Some(
if fragment.is_empty() {
default
} else {
fragment
}
.to_string(),
)
}
fn heading_anchors(source: &str) -> HashMap<String, f32> {
let total = source.lines().count().max(1) as f32;
let mut in_fence = false;
let mut counts = HashMap::<String, usize>::new();
let mut anchors = HashMap::new();
for (line_index, line) in source.lines().enumerate() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
} else if !in_fence {
let hashes = line
.chars()
.take_while(|character| *character == '#')
.count();
if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') {
let base = github_slug(line[hashes + 1..].trim());
if !base.is_empty() {
let count = counts.entry(base.clone()).or_default();
let slug = if *count == 0 {
base.clone()
} else {
format!("{base}-{count}")
};
*count += 1;
anchors.insert(slug, line_index as f32 / total);
}
}
}
}
anchors
}
fn github_slug(heading: &str) -> String {
let mut slug = String::new();
for character in heading.to_lowercase().chars() {
if character.is_alphanumeric() || character == '-' || character == '_' {
slug.push(character);
} else if character.is_whitespace() {
slug.push('-');
}
}
slug.trim_matches('-').to_string()
}
fn file_signature(path: &Path) -> u64 {
let Ok(metadata) = fs::metadata(path) else {
return 0;
};
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
metadata.len().hash(&mut hasher);
modified.hash(&mut hasher);
hasher.finish()
}
fn paths_signature(paths: impl IntoIterator<Item = impl AsRef<Path>>) -> u64 {
let mut hasher = DefaultHasher::new();
for path in paths {
let path: PathBuf = path.as_ref().to_path_buf();
path.hash(&mut hasher);
file_signature(&path).hash(&mut hasher);
}
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_guide_states_cover_existing_missing_and_invalid_utf8() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("DOCUMENTATION.md");
assert!(matches!(
load_document_file(&path),
DocumentLoad::Missing { .. }
));
std::fs::write(&path, "# Project guide\n\nSafe **Markdown**.").unwrap();
let DocumentLoad::Ready { source, .. } = load_document_file(&path) else {
panic!("existing documentation should load");
};
assert!(source.contains("Project guide"));
std::fs::write(&path, [0xff, 0xfe]).unwrap();
assert!(matches!(
load_document_file(&path),
DocumentLoad::Malformed { .. }
));
}
#[test]
fn explicit_user_guide_reload_observes_source_changes() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("DOCUMENTATION.md");
std::fs::write(&path, "# First").unwrap();
let DocumentLoad::Ready {
source: first,
signature: first_signature,
} = load_document_file(&path)
else {
panic!("first document should load");
};
std::fs::write(&path, "# Second and longer").unwrap();
let DocumentLoad::Ready {
source: second,
signature: second_signature,
} = load_document_file(&path)
else {
panic!("changed document should load");
};
assert_ne!(first, second);
assert_ne!(first_signature, second_signature);
assert_eq!(file_signature(&path), second_signature);
}
#[test]
fn packaged_user_guide_is_bundled_and_global() {
let DocumentLoad::Ready { source, .. } = load_user_guide() else {
panic!("the bundled user guide should always load");
};
assert!(source.starts_with("# RuDS User Guide"));
let rewritten = rewrite_links(&source);
let parsed = parse_document(&source);
let anchor_pattern =
regex::Regex::new(r"https://ruds\.invalid/document#([^)\s]+)").unwrap();
for captures in anchor_pattern.captures_iter(&rewritten) {
assert!(
parsed.anchors.contains_key(&captures[1]),
"guide link points to missing heading anchor: {}",
&captures[1]
);
}
assert!(rewritten.contains(API_DOCUMENTATION_URL));
}
#[test]
fn api_document_uses_generated_reference_types_and_examples() {
let DocumentLoad::Ready { source, .. } = load_api_document() else {
panic!("bundled API documentation should always load");
};
assert!(source.contains("# RuDS Lua API Reference"));
assert!(source.contains("# Lua API Types"));
assert!(source.contains("## Utility Lua example"));
assert!(source.contains("function main(input)"));
let types_offset = parse_document(&source).anchors["lua-api-types"];
assert!((0.90..0.96).contains(&types_offset));
}
#[test]
fn safe_document_parser_keeps_tables_and_navigation_but_never_active_content() {
let source = "# Start\n\n[Jump](#details) [API](API.md)\n\n| Name | Value |\n| --- | --- |\n| one | `two` |\n\n## Details\n\n<script>alert(1)</script>\n<style>bad</style>";
let rewritten = rewrite_links(source);
assert!(rewritten.contains("https://ruds.invalid/document#details"));
assert!(rewritten.contains(API_DOCUMENTATION_URL));
let parsed = parse_document(source);
assert!(parsed.blocks.iter().any(|block| matches!(
block,
DocumentBlock::Table { headers, rows }
if headers == &["Name", "Value"]
&& rows == &[vec![String::from("one"), String::from("two")]]
)));
assert!(parsed.anchors.contains_key("details"));
let debug = format!("{:?}", parsed.blocks);
assert!(debug.contains("Heading"));
assert!(!debug.contains("<script>"));
assert!(!debug.contains("<style>"));
}
#[test]
fn tables_keep_union_type_cells_and_fenced_table_text_stays_code() {
let parsed = parse_document(
"| Type | Meaning |\n| --- | --- |\n| `string | nil` | optional |\n\n```md\n| not | a table |\n| --- | --- |\n```",
);
assert!(matches!(
&parsed.blocks[0],
DocumentBlock::Table { rows, .. }
if rows == &[vec![String::from("string | nil"), String::from("optional")]]
));
assert!(matches!(
&parsed.blocks[1],
DocumentBlock::Markdown(items)
if format!("{items:?}").contains("CodeBlock")
&& format!("{items:?}").contains("not | a table")
));
}
}

View File

@@ -2,6 +2,7 @@ pub mod activity_bar;
pub mod chat_surfaces;
pub mod chat_view;
pub mod dashboard;
pub mod documentation;
pub mod duplicates;
pub mod git;
pub mod import_editor;

View File

@@ -116,6 +116,7 @@ pub enum PostInsertLinkTab {
#[derive(Debug, Clone)]
pub enum ConfirmAction {
RebuildSearchIndex,
OpenExternalUrl(String),
DeleteProject(String),
DeletePost(String),
DeleteMedia(String),

View File

@@ -18,6 +18,7 @@ use crate::views::{
activity_bar,
chat_view::{self, ChatEditorState},
dashboard::DashboardState,
documentation::{self, DocumentationState},
duplicates::{self, DuplicatesState},
git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState},
@@ -130,6 +131,8 @@ pub fn view<'a>(
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
guide_documentation: &'a DocumentationState,
api_documentation: &'a DocumentationState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState,
@@ -161,6 +164,8 @@ pub fn view<'a>(
dashboard_state,
site_validation_state,
duplicates_state,
guide_documentation,
api_documentation,
metadata_diff_state,
translation_validation_state,
git_diffs,
@@ -404,6 +409,8 @@ fn route_content_area<'a>(
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
guide_documentation: &'a DocumentationState,
api_documentation: &'a DocumentationState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>,
@@ -494,6 +501,8 @@ fn route_content_area<'a>(
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
ContentRoute::Documentation => documentation::view(guide_documentation, locale),
ContentRoute::ApiDocumentation => documentation::view(api_documentation, locale),
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale)
@@ -530,6 +539,8 @@ enum ContentRoute<'a> {
Settings,
SiteValidation,
FindDuplicates,
Documentation,
ApiDocumentation,
MetadataDiff,
TranslationValidation,
GitDiff(&'a str),
@@ -619,10 +630,9 @@ fn route_kind<'a>(
TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::FindDuplicates => ContentRoute::FindDuplicates,
TabType::Style
| TabType::MenuEditor
| TabType::Documentation
| TabType::ApiDocumentation => ContentRoute::Placeholder(&tab.title),
TabType::Documentation => ContentRoute::Documentation,
TabType::ApiDocumentation => ContentRoute::ApiDocumentation,
TabType::Style | TabType::MenuEditor => ContentRoute::Placeholder(&tab.title),
TabType::TranslationValidation => ContentRoute::TranslationValidation,
}
}
@@ -679,12 +689,7 @@ mod tests {
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation_state = SiteValidationState::default();
let unsupported = [
TabType::Style,
TabType::MenuEditor,
TabType::Documentation,
TabType::ApiDocumentation,
];
let unsupported = [TabType::Style, TabType::MenuEditor];
for tab_type in unsupported {
let tabs = vec![tab("tool", tab_type.clone(), "Tool")];
@@ -708,6 +713,39 @@ mod tests {
}
}
#[test]
fn documentation_tabs_route_to_real_read_only_views() {
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation = SiteValidationState::default();
for (tab_type, expected_api) in [
(TabType::Documentation, false),
(TabType::ApiDocumentation, true),
] {
let tabs = vec![tab("docs", tab_type, "Docs")];
let route = route_kind(
&tabs,
Some("docs"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
&site_validation,
);
assert!(matches!(
(route, expected_api),
(ContentRoute::Documentation, false) | (ContentRoute::ApiDocumentation, true)
));
}
}
#[test]
fn duplicate_tab_routes_to_real_review_surface() {
let tabs = vec![tab(

View File

@@ -36,6 +36,18 @@ duplicates-exactMatch = Exakte Übereinstimmung
duplicates-dismiss = Verwerfen
duplicates-dismissed = Duplikatpaar wurde verworfen.
duplicates-showMore = Mehr anzeigen
documentation-title = Dokumentation
documentation-apiTitle = Lua-API-Dokumentation
documentation-subtitle = Schreibgeschütztes, mitgeliefertes RuDS-Benutzerhandbuch.
documentation-apiSubtitle = Generierte Lua-Referenz, öffentliche Typen und ausführbare Beispiele.
documentation-loading = Dokumentation wird geladen…
documentation-missing = Das RuDS-Benutzerhandbuch ist nicht verfügbar.
documentation-malformed = Die Dokumentationsquelle konnte nicht als UTF-8-Text gelesen werden.
documentation-empty = Diese Dokumentationsdatei ist leer.
documentation-anchorMissing = Dieser Abschnitt ist in diesem Dokument nicht vorhanden.
documentation-linkRefused = Nur Dokumentabschnitte sowie HTTP- oder HTTPS-Links können geöffnet werden.
documentation-externalLinkTitle = Externen Link öffnen?
documentation-externalLinkMessage = RuDS öffnet diese Adresse im Standardbrowser: { $url }
embeddings-reindexed = Embeddings für { $count } Beiträge neu erstellt.
embeddings-indexing = Semantische Inhalte werden indexiert
embeddings-indexingProgress = Beitrag { $current } von { $total } wird indexiert
@@ -50,6 +62,7 @@ menu-item-fillMissingTranslations = Fehlende Übersetzungen ausfüllen
menu-item-uploadSite = Website hochladen
menu-item-about = Über Blogging Desktop Server
menu-item-openDocumentation = Dokumentation öffnen
menu-item-openApiDocumentation = Lua-API-Dokumentation öffnen
menu-item-viewOnGitHub = Auf GitHub ansehen
menu-item-reportIssue = Problem melden
activity-posts = Beiträge

View File

@@ -36,6 +36,18 @@ duplicates-exactMatch = Exact Match
duplicates-dismiss = Dismiss
duplicates-dismissed = Duplicate pair dismissal saved.
duplicates-showMore = Show More
documentation-title = Documentation
documentation-apiTitle = Lua API Documentation
documentation-subtitle = Read-only bundled RuDS user guide.
documentation-apiSubtitle = Generated Lua reference, public types, and runnable examples.
documentation-loading = Loading documentation…
documentation-missing = The RuDS user guide is unavailable.
documentation-malformed = The documentation source could not be read as UTF-8 text.
documentation-empty = This documentation file is empty.
documentation-anchorMissing = That section is not present in this document.
documentation-linkRefused = Only document sections and HTTP or HTTPS links can be opened.
documentation-externalLinkTitle = Open external link?
documentation-externalLinkMessage = RuDS will open this address in your default browser: { $url }
embeddings-reindexed = Rebuilt embeddings for { $count } posts.
embeddings-indexing = Indexing semantic content
embeddings-indexingProgress = Indexing post { $current } of { $total }
@@ -50,6 +62,7 @@ menu-item-fillMissingTranslations = Fill Missing Translations
menu-item-uploadSite = Upload Site
menu-item-about = About Blogging Desktop Server
menu-item-openDocumentation = Open Documentation
menu-item-openApiDocumentation = Open Lua API Documentation
menu-item-viewOnGitHub = View on GitHub
menu-item-reportIssue = Report Issue
activity-posts = Posts

View File

@@ -36,6 +36,18 @@ duplicates-exactMatch = Coincidencia exacta
duplicates-dismiss = Descartar
duplicates-dismissed = Se descartó el par duplicado.
duplicates-showMore = Mostrar más
documentation-title = Documentación
documentation-apiTitle = Documentación de la API de Lua
documentation-subtitle = Guía del usuario de RuDS incluida y de solo lectura.
documentation-apiSubtitle = Referencia de Lua generada, tipos públicos y ejemplos ejecutables.
documentation-loading = Cargando documentación…
documentation-missing = La guía del usuario de RuDS no está disponible.
documentation-malformed = No se pudo leer la fuente de documentación como texto UTF-8.
documentation-empty = Este archivo de documentación está vacío.
documentation-anchorMissing = Esa sección no está presente en este documento.
documentation-linkRefused = Solo se pueden abrir secciones del documento y enlaces HTTP o HTTPS.
documentation-externalLinkTitle = ¿Abrir enlace externo?
documentation-externalLinkMessage = RuDS abrirá esta dirección en el navegador predeterminado: { $url }
embeddings-reindexed = Se reconstruyeron los embeddings de { $count } publicaciones.
embeddings-indexing = Indexando contenido semántico
embeddings-indexingProgress = Indexando publicación { $current } de { $total }
@@ -50,6 +62,7 @@ menu-item-fillMissingTranslations = Completar traducciones faltantes
menu-item-uploadSite = Subir sitio
menu-item-about = Acerca de Blogging Desktop Server
menu-item-openDocumentation = Abrir documentación
menu-item-openApiDocumentation = Abrir documentación de la API de Lua
menu-item-viewOnGitHub = Ver en GitHub
menu-item-reportIssue = Reportar problema
activity-posts = Entradas

View File

@@ -36,6 +36,18 @@ duplicates-exactMatch = Correspondance exacte
duplicates-dismiss = Ignorer
duplicates-dismissed = La paire de doublons a été ignorée.
duplicates-showMore = Afficher plus
documentation-title = Documentation
documentation-apiTitle = Documentation de lAPI Lua
documentation-subtitle = Guide utilisateur RuDS intégré en lecture seule.
documentation-apiSubtitle = Référence Lua générée, types publics et exemples exécutables.
documentation-loading = Chargement de la documentation…
documentation-missing = Le guide utilisateur RuDS nest pas disponible.
documentation-malformed = La source de documentation na pas pu être lue comme texte UTF-8.
documentation-empty = Ce fichier de documentation est vide.
documentation-anchorMissing = Cette section nest pas présente dans ce document.
documentation-linkRefused = Seules les sections du document et les liens HTTP ou HTTPS peuvent être ouverts.
documentation-externalLinkTitle = Ouvrir le lien externe ?
documentation-externalLinkMessage = RuDS ouvrira cette adresse dans le navigateur par défaut : { $url }
embeddings-reindexed = Embeddings reconstruits pour { $count } articles.
embeddings-indexing = Indexation du contenu sémantique
embeddings-indexingProgress = Indexation de larticle { $current } sur { $total }
@@ -50,6 +62,7 @@ menu-item-fillMissingTranslations = Compléter les traductions manquantes
menu-item-uploadSite = Publier le site
menu-item-about = À propos de Blogging Desktop Server
menu-item-openDocumentation = Ouvrir la documentation
menu-item-openApiDocumentation = Ouvrir la documentation de lAPI Lua
menu-item-viewOnGitHub = Voir sur GitHub
menu-item-reportIssue = Signaler un problème
activity-posts = Articles

View File

@@ -36,6 +36,18 @@ duplicates-exactMatch = Corrispondenza esatta
duplicates-dismiss = Ignora
duplicates-dismissed = Coppia duplicata ignorata.
duplicates-showMore = Mostra altro
documentation-title = Documentazione
documentation-apiTitle = Documentazione API Lua
documentation-subtitle = Guida utente RuDS inclusa e in sola lettura.
documentation-apiSubtitle = Riferimento Lua generato, tipi pubblici ed esempi eseguibili.
documentation-loading = Caricamento della documentazione…
documentation-missing = La guida utente RuDS non è disponibile.
documentation-malformed = Non è stato possibile leggere la documentazione come testo UTF-8.
documentation-empty = Questo file di documentazione è vuoto.
documentation-anchorMissing = Questa sezione non è presente nel documento.
documentation-linkRefused = È possibile aprire solo sezioni del documento e collegamenti HTTP o HTTPS.
documentation-externalLinkTitle = Aprire il collegamento esterno?
documentation-externalLinkMessage = RuDS aprirà questo indirizzo nel browser predefinito: { $url }
embeddings-reindexed = Embedding ricostruiti per { $count } post.
embeddings-indexing = Indicizzazione dei contenuti semantici
embeddings-indexingProgress = Indicizzazione del post { $current } di { $total }
@@ -50,6 +62,7 @@ menu-item-fillMissingTranslations = Completa le traduzioni mancanti
menu-item-uploadSite = Carica sito
menu-item-about = Informazioni su Blogging Desktop Server
menu-item-openDocumentation = Apri documentazione
menu-item-openApiDocumentation = Apri documentazione API Lua
menu-item-viewOnGitHub = Visualizza su GitHub
menu-item-reportIssue = Segnala problema
activity-posts = Post