diff --git a/README.md b/README.md index 7171f013..7e1ea7e0 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,9 @@ page, each with a temporary password that works for 72 hours and is changed at t The panel's pages are grouped by what one comes for: **Players** (the players, the live map, the bans), **Server** (restarts, the cfg, builds, backups, file changes), **Insights** (statistics, drops, diagnostics) and **Maintenance** (tools, the audit log). The search button beside the logo, or Ctrl K -(⌘ K on a Mac) from any page, opens a search of the pages, of the settings by their name, and of the -connected players by name or ID. Each account finds only what it may open. +(⌘ K on a Mac) from any page, opens a search of the pages, of the settings by their name, of the +events and exports the **Documentation** page describes (see [Integrations](#integrations)), and of +the connected players by name or ID. Each account finds only what it may open. From the host, even while Observer runs: @@ -997,6 +998,13 @@ which the `observer_bridge` server convar gives: do not ensure it yourself. Scri fixed name, as they do txAdmin's `monitor`, find nothing. [`resource/observer_example`](resource/observer_example) listens to each event and marks where a team plugs in its notifications and its saves. +The panel's **Documentation** page shows that contract as the build that runs ships it: each event with +when it comes, its fields and a handler to copy, each export where the contract shows it called, and the +files of the example resource. Every section has a link of its own, and the panel's search (Ctrl K) finds +an event, an export or one of txAdmin's events by its name. Any account reads it. To the accounts that +see the dashboard, it also says what this server does now: the contract's version, the name the bridge +took for this start, and whether txAdmin's events are raised, now and from the next start. + Your resources can also give each connected player a label, a roleplay character's name or an ID of your own, through the bridge's `setPlayerLabel` export. The panel shows it beside the player's FiveM name, marked as your resources' word, keeps each label on the player's profile as long as their names, diff --git a/crates/fake-fxserver/tests/supervisor.rs b/crates/fake-fxserver/tests/supervisor.rs index 6ba0b2fc..43b1982b 100644 --- a/crates/fake-fxserver/tests/supervisor.rs +++ b/crates/fake-fxserver/tests/supervisor.rs @@ -425,6 +425,39 @@ async fn a_new_launch_applies_from_the_next_start() { lab.shut_down(false).await; } +#[tokio::test] +async fn the_status_says_whether_the_running_server_raises_txadmins_events() { + let mut lab = Lab::online(&[]).await; + let mut status = lab.supervisor.status(); + assert_eq!(status.borrow().txadmin_events, Some(false)); + let mut launch = lab.launch.clone(); + launch.settings.txadmin_events = true; + + lab.supervisor.configure(launch).await; + tokio::time::sleep(Duration::from_millis(500)).await; + assert_eq!( + status.borrow().txadmin_events, + Some(false), + "the running server keeps its launch" + ); + lab.supervisor.restart("test").await.unwrap(); + + timeout( + WAIT, + status.wait_for(|status| status.txadmin_events == Some(true)), + ) + .await + .expect("the restart did not take the new launch") + .unwrap(); + lab.supervisor.stop("test").await.unwrap(); + timeout(WAIT, status.wait_for(|status| status.pid.is_none())) + .await + .expect("the server did not stop") + .unwrap(); + assert_eq!(status.borrow().txadmin_events, None); + lab.shut_down(false).await; +} + #[tokio::test] async fn a_started_server_goes_online_then_stops_gracefully() { let mut lab = Lab::new(&[]).await; diff --git a/crates/observer-app/src/docs.rs b/crates/observer-app/src/docs.rs new file mode 100644 index 00000000..b49ffc54 --- /dev/null +++ b/crates/observer-app/src/docs.rs @@ -0,0 +1,59 @@ +//! What the panel's Documentation page is built from: the integration contract as this build +//! ships it, and what the server Observer runs does of it. + +use observer_domain::docs::{BridgeExport, ExampleFile, IntegrationDocs, IntegrationServer}; +use observer_domain::permissions::Permissions; +use observer_fxserver::supervisor::State; +use observer_fxserver::{bridge, contract}; + +use crate::{Caller, Error, Servers, Supervision}; + +/// The contract with its example, what the bridge raises and exports, and this server's side of +/// it. The contract holds no secret, so any account reads it. What the server does now goes to +/// those who see its state elsewhere in the panel, the holders of the dashboard's permission. +pub async fn integration_docs( + caller: &Caller, + supervision: &Supervision, + servers: &Servers, +) -> Result { + let server = if caller.permissions.contains(Permissions::DASHBOARD_VIEW) { + Some(server_side(supervision, servers).await?) + } else { + None + }; + Ok(IntegrationDocs { + contract: contract::TEXT.to_owned(), + contract_version: bridge::CONTRACT_VERSION, + events: contract::events(), + exports: contract::exports() + .into_iter() + .map(|export| BridgeExport { + name: export.name.to_owned(), + parameters: export.parameters.into_iter().map(str::to_owned).collect(), + }) + .collect(), + example: contract::EXAMPLE + .into_iter() + .map(|(name, text)| ExampleFile { + name: name.to_owned(), + text: text.to_owned(), + }) + .collect(), + server, + }) +} + +async fn server_side(supervision: &Supervision, servers: &Servers) -> Result { + let next = servers + .launch_settings(Servers::DEFAULT) + .await? + .map(|stored| stored.settings.txadmin_events); + let status = supervision.supervisor().status(); + let status = status.borrow(); + Ok(IntegrationServer { + bridge: status.bridge.clone(), + starting: status.state == State::Starting, + txadmin_events: status.txadmin_events, + txadmin_events_next: next, + }) +} diff --git a/crates/observer-app/src/lib.rs b/crates/observer-app/src/lib.rs index 6d766a71..9a3c44a4 100644 --- a/crates/observer-app/src/lib.rs +++ b/crates/observer-app/src/lib.rs @@ -12,6 +12,7 @@ mod changes; mod clock; mod debug; mod diagnostics; +mod docs; mod drops; mod error; mod features; @@ -49,6 +50,7 @@ pub use changes::{Changes, ChangesSetup, ChangesTask, ComparedTo, DiffOptions}; pub use clock::Clock; pub use debug::{DebugCounts, DebugSetup, DebugTask}; pub use diagnostics::{Diagnostics, DiagnosticsSetup, DiagnosticsTask, MAX_DOWNLOAD}; +pub use docs::integration_docs; pub use drops::{DayRates, Drops, DropsSetup, DropsTask, LogQuery}; pub use error::Error; pub use features::{Features, FeaturesSetup, Switched}; diff --git a/crates/observer-domain/src/docs.rs b/crates/observer-domain/src/docs.rs new file mode 100644 index 00000000..771f11fb --- /dev/null +++ b/crates/observer-domain/src/docs.rs @@ -0,0 +1,58 @@ +//! What the panel's Documentation page is built from: the integration contract as this build +//! ships it, and what the server Observer runs does of it. + +use serde::Serialize; +use ts_rs::TS; + +/// `GET /api/docs/integration`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct IntegrationDocs { + /// The contract in Markdown, as this build ships it: the page is drawn from it. + pub contract: String, + pub contract_version: u32, + /// Every server event the bridge raises, as resources hear it: `observer:shuttingDown`. + pub events: Vec, + pub exports: Vec, + /// The example resource, file by file. + pub example: Vec, + /// Null for an account that does not see the server's state, which takes the dashboard's + /// permission: the contract is anyone's to read, what the server does now is not. + pub server: Option, +} + +/// An export of the bridge, as its script declares it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct BridgeExport { + pub name: String, + pub parameters: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ExampleFile { + /// From the contract's own folder, as its links name it: `observer_example/server.lua`. + pub name: String, + pub text: String, +} + +/// What the server Observer runs does of the contract. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct IntegrationServer { + /// The bridge's name in the server that runs now, drawn for its start. Null while no server + /// runs. + pub bridge: Option, + /// That server is still starting: its bridge may not have reported yet. + pub starting: bool, + /// Whether the server that runs now raises txAdmin's events too. Null while no server runs. + pub txadmin_events: Option, + /// Whether the next start will, as the launch settings have it. Null until the server is set + /// up. + pub txadmin_events_next: Option, +} diff --git a/crates/observer-domain/src/lib.rs b/crates/observer-domain/src/lib.rs index 74c2b3d1..0927bc76 100644 --- a/crates/observer-domain/src/lib.rs +++ b/crates/observer-domain/src/lib.rs @@ -17,6 +17,7 @@ pub mod convars; pub mod debug; pub mod delegation; pub mod diagnostics; +pub mod docs; pub mod drops; pub mod error; pub mod features; diff --git a/crates/observer-fxserver/src/bridge.rs b/crates/observer-fxserver/src/bridge.rs index b590fa7f..b8f00d5b 100644 --- a/crates/observer-fxserver/src/bridge.rs +++ b/crates/observer-fxserver/src/bridge.rs @@ -46,6 +46,11 @@ const FILES: [(&str, &str); 3] = [ ("heap.js", include_str!("../../../resource/observer/heap.js")), ]; +/// The bridge's script, which the contract's documentation reads its events and exports from. +pub(crate) fn script() -> &'static str { + FILES[1].1 +} + /// Draws the bridge's resource name for one start of FXServer, so that no script can target the /// bridge by a name known before the start. pub fn draw_name() -> io::Result { diff --git a/crates/observer-fxserver/src/contract.rs b/crates/observer-fxserver/src/contract.rs new file mode 100644 index 00000000..68516f1d --- /dev/null +++ b/crates/observer-fxserver/src/contract.rs @@ -0,0 +1,202 @@ +//! The integration contract as it ships, for the panel's documentation: `resource/CONTRACT.md` +//! itself, the example resource that follows it, and what the bridge's script raises and exports. +//! The contract is written once, in `resource/`: nothing here says it again. + +use crate::bridge; + +/// `resource/CONTRACT.md`, in Markdown. +pub const TEXT: &str = include_str!("../../../resource/CONTRACT.md"); + +/// The example resource, file by file. Each is named from `resource/`, as the contract's own +/// links are: the one it gives to `observer_example` leads to these. +pub const EXAMPLE: [(&str, &str); 2] = [ + ( + "observer_example/fxmanifest.lua", + include_str!("../../../resource/observer_example/fxmanifest.lua"), + ), + ( + "observer_example/server.lua", + include_str!("../../../resource/observer_example/server.lua"), + ), +]; + +/// What every event of the contract starts with. +pub const EVENT_PREFIX: &str = "observer:"; + +/// An export of the bridge, as its script declares it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Export { + pub name: &'static str, + pub parameters: Vec<&'static str>, +} + +/// Every server event the bridge raises, as resources hear it (`observer:shuttingDown`), read +/// from its script: those Observer has it raise by name, then those it raises on its own. +pub fn events() -> Vec { + events_of(bridge::script()) +} + +/// Every export of the bridge, in the order its script declares them. +pub fn exports() -> Vec { + exports_of(bridge::script()) +} + +fn events_of(script: &str) -> Vec { + let table = script + .split_once("local EVENTS = {\n") + .and_then(|(_, rest)| rest.split_once("\n}")) + .map_or("", |(table, _)| table); + let named = table + .lines() + .filter_map(|line| line.trim().strip_suffix(" = true,")); + // `TriggerEvent('observer:' .. name` raises the named ones: it holds no name of its own. + let own = script + .split("TriggerEvent('") + .skip(1) + .filter_map(|rest| rest.split_once('\'')?.0.strip_prefix(EVENT_PREFIX)) + .filter(|name| !name.is_empty()); + let mut events: Vec = Vec::new(); + for name in named.chain(own) { + let event = format!("{EVENT_PREFIX}{name}"); + if !events.contains(&event) { + events.push(event); + } + } + events +} + +fn exports_of(script: &'static str) -> Vec { + script + .lines() + .filter_map(|line| { + let (name, rest) = line.strip_prefix("exports('")?.split_once('\'')?; + let (parameters, _) = rest.split_once("function(")?.1.split_once(')')?; + Some(Export { + name, + parameters: parameters + .split(',') + .map(str::trim) + .filter(|parameter| !parameter.is_empty()) + .collect(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_events_are_those_the_script_names_and_those_it_raises_on_its_own() { + let script = "local EVENTS = {\n shuttingDown = true,\n startChanges = true,\n}\n\ + local OTHER = {\n verdict = true,\n}\n\ + TriggerEvent('observer:playerRefused', refusal.event)\n\ + TriggerEvent('observer:playerRefused', again)\n\ + TriggerEvent('observer:' .. command.event, command.payload)\n\ + TriggerEvent(event[1], event[2])\n\ + TriggerEvent('txAdmin:events:scheduledRestart', {})\n"; + + assert_eq!( + events_of(script), + [ + "observer:shuttingDown", + "observer:startChanges", + "observer:playerRefused" + ] + ); + } + + #[test] + fn the_exports_come_with_their_parameters() { + let script = "exports('getContractVersion', function()\n return 1\nend)\n\ + exports('setPlayerLabel', function(player, label)\nend)\n\ + local function exports_of() end\n"; + + assert_eq!( + exports_of(script), + [ + Export { + name: "getContractVersion", + parameters: vec![] + }, + Export { + name: "setPlayerLabel", + parameters: vec!["player", "label"] + }, + ] + ); + } + + #[test] + fn the_bridge_raises_events_and_has_exports() { + let events = events(); + let exports = exports(); + + // One Observer has it raise by name, one it raises on its own. + for event in ["observer:shuttingDown", "observer:playerBanned"] { + assert!(events.iter().any(|raised| raised == event), "{event}"); + } + assert!(exports.iter().any(|export| export.name == "getContractVersion")); + } + + #[test] + fn the_contract_gives_every_event_of_the_bridge_a_section_and_says_when_it_comes() { + for event in events() { + assert!( + TEXT.contains(&format!("\n### `{event}`\n")), + "CONTRACT.md has no section for {event}" + ); + assert!( + TEXT.contains(&format!("\n| `{event}` | ")), + "no table of CONTRACT.md says when {event} comes" + ); + } + } + + #[test] + fn the_contract_has_no_section_for_an_event_the_bridge_does_not_raise() { + let events = events(); + + for documented in TEXT + .lines() + .filter_map(|line| line.strip_prefix("### `")?.strip_suffix('`')) + .filter(|heading| heading.starts_with(EVENT_PREFIX)) + { + assert!( + events.iter().any(|event| event == documented), + "the bridge does not raise {documented}" + ); + } + } + + #[test] + fn the_contract_shows_every_export_of_the_bridge_called() { + for export in exports() { + assert!( + TEXT.contains(&format!("]:{}(", export.name)), + "CONTRACT.md does not show {} called", + export.name + ); + } + } + + #[test] + fn the_example_resource_ships_whole_and_listens_to_every_event() { + let [(manifest, declared), (script, text)] = EXAMPLE; + + assert_eq!(manifest, "observer_example/fxmanifest.lua"); + assert_eq!(script, "observer_example/server.lua"); + assert!( + TEXT.contains("](observer_example)"), + "CONTRACT.md links to the example" + ); + assert!(declared.contains("server_script 'server.lua'")); + for event in events() { + assert!( + text.contains(&format!("AddEventHandler('{event}'")), + "the example does not listen to {event}" + ); + } + } +} diff --git a/crates/observer-fxserver/src/lib.rs b/crates/observer-fxserver/src/lib.rs index 32a14fa4..7d44f763 100644 --- a/crates/observer-fxserver/src/lib.rs +++ b/crates/observer-fxserver/src/lib.rs @@ -6,6 +6,7 @@ pub mod cfg_edit; pub mod changes; pub mod command; pub mod console; +pub mod contract; pub mod crashes; pub mod exit; pub mod folders; diff --git a/crates/observer-fxserver/src/supervisor.rs b/crates/observer-fxserver/src/supervisor.rs index 5839b712..05b9b2ad 100644 --- a/crates/observer-fxserver/src/supervisor.rs +++ b/crates/observer-fxserver/src/supervisor.rs @@ -429,6 +429,8 @@ pub struct Status { pub port: Option, /// The bridge's resource name in the server that runs now, drawn for its start. pub bridge: Option, + /// Whether the server that runs now raises txAdmin's events too, as it was launched. + pub txadmin_events: Option, /// How far the start has come, while the server is starting. pub start: Option, } @@ -638,6 +640,7 @@ impl Supervisor { name: None, port: None, bridge: None, + txadmin_events: None, start: None, }; let (status_tx, status) = watch::channel(initial); @@ -1026,6 +1029,8 @@ struct Run { layout: Layout, /// The bridge's name in this run: only its reports are taken. bridge: String, + /// Whether this run was launched to raise txAdmin's events too. + txadmin_events: bool, exit: ExitWatch, tree: Arc, stdin: StdinWriter, @@ -2335,6 +2340,7 @@ impl Actor { game_build: None, layout, bridge: prepared.bridge, + txadmin_events: prepared.settings.txadmin_events, exit: spawned.exit, tree, stdin, @@ -2627,6 +2633,7 @@ impl Actor { name: self.name.clone(), port: self.port, bridge: run.map(|run| run.bridge.clone()), + txadmin_events: run.map(|run| run.txadmin_events), start: run .filter(|_| self.state == State::Starting) .and_then(|run| run.start.as_ref()) diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index cffc4ddb..3a6424af 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -13,6 +13,7 @@ mod cfg; mod changes; mod debug; mod diagnostics; +mod docs; mod drops; mod features; mod files; @@ -235,6 +236,7 @@ pub(crate) fn routes() -> Vec { route(M::GET, "/metrics/runs", dashboard, metrics::runs), route(M::GET, "/about", Session, about::about), route(M::GET, "/about/licences", Session, about::licences), + route(M::GET, "/docs/integration", Session, docs::integration), route(M::GET, "/servers/default/crashes/{id}/files/{name}", dumps, diagnostics::download_file), route(M::DELETE, "/servers/default/crashes/{id}/files", dumps, diagnostics::delete_files), route(M::GET, "/servers/default/players", players, players::page), diff --git a/crates/observer-web/src/api/docs.rs b/crates/observer-web/src/api/docs.rs new file mode 100644 index 00000000..861434ec --- /dev/null +++ b/crates/observer-web/src/api/docs.rs @@ -0,0 +1,20 @@ +//! What the panel's Documentation page is built from. The contract holds no secret, so any +//! signed-in account reads it, as the About page; the service leaves the server's state out for +//! an account that does not see it. + +use axum::extract::State; +use axum::{Extension, Json}; +use observer_app::Caller; +use observer_domain::docs::IntegrationDocs; + +use crate::AppState; +use crate::error::Failure; + +pub(crate) async fn integration( + State(state): State, + Extension(caller): Extension, +) -> Result, Failure> { + Ok(Json( + observer_app::integration_docs(&caller, &state.supervision, &state.servers).await?, + )) +} diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index 4889f8e0..49004522 100644 --- a/crates/observer-web/src/tests.rs +++ b/crates/observer-web/src/tests.rs @@ -8,6 +8,7 @@ mod cfg; mod changes; mod debug; mod diagnostics; +mod docs; mod drops; mod features; mod files; diff --git a/crates/observer-web/src/tests/docs.rs b/crates/observer-web/src/tests/docs.rs new file mode 100644 index 00000000..48839f1f --- /dev/null +++ b/crates/observer-web/src/tests/docs.rs @@ -0,0 +1,92 @@ +//! The documentation's source over HTTP: who reads it, that what it serves is the contract this +//! build ships, and who is told what the server does of it. + +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; + +use super::Server; + +const DOCS: &str = "/api/docs/integration"; + +#[tokio::test] +async fn any_signed_in_account_reads_the_contract_this_build_ships() { + let server = Server::new().await; + let owner = server.owner().await; + let nobody = server.member(&owner, "alan", "0").await; + + let read = server.call(Method::GET, DOCS, Some(&nobody), None).await; + let stranger = server.get(DOCS).await; + + assert_eq!(read.status, StatusCode::OK, "{}", read.text); + assert_eq!(stranger.status, StatusCode::UNAUTHORIZED); + let docs = read.json(); + // Whether the server runs is the dashboard's to say. + assert_eq!(docs["server"], Value::Null); + let contract = docs["contract"].as_str().unwrap(); + assert!(contract.starts_with("# Integration contract\n")); + let version = docs["contractVersion"].as_u64().unwrap(); + assert!(contract.contains(&format!("contract version **{version}**"))); + let events: Vec<&str> = docs["events"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); + assert!(events.contains(&"observer:shuttingDown"), "{events:?}"); + for event in events { + assert!(contract.contains(&format!("### `{event}`")), "{event}"); + } + assert_eq!( + docs["exports"][0], + json!({ "name": "getContractVersion", "parameters": [] }) + ); + let example: Vec<&str> = docs["example"] + .as_array() + .unwrap() + .iter() + .filter_map(|file| file["name"].as_str()) + .collect(); + assert_eq!( + example, + ["observer_example/fxmanifest.lua", "observer_example/server.lua"] + ); +} + +#[tokio::test] +async fn the_servers_side_goes_to_whoever_sees_the_dashboard_and_follows_the_launch_settings() { + let server = Server::new().await; + let owner = server.owner().await; + let viewer = server.member(&owner, "grace", "1").await; + + let before = server.call(Method::GET, DOCS, Some(&owner), None).await.json(); + let settings = json!({ + "dataPath": server.dir.path().join("server-data").to_str().unwrap(), + "cfgFile": "server.cfg", + "onesync": "on", + "autoStart": false, + "txadminEvents": true, + "extraArguments": [], + }); + let saved = server + .call( + Method::PUT, + "/api/servers/default/launch", + Some(&owner), + Some(json!({ "settings": settings, "version": null })), + ) + .await; + assert_eq!(saved.status, StatusCode::OK, "{}", saved.text); + let after = server.call(Method::GET, DOCS, Some(&owner), None).await.json(); + let seen = server.call(Method::GET, DOCS, Some(&viewer), None).await.json(); + + assert_eq!( + before["server"], + json!({ "bridge": null, "starting": false, "txadminEvents": null, "txadminEventsNext": null }) + ); + // No server runs: only the next start is known. + assert_eq!( + after["server"], + json!({ "bridge": null, "starting": false, "txadminEvents": null, "txadminEventsNext": true }) + ); + assert_eq!(seen["server"], after["server"]); +} diff --git a/web/src/api/docs.ts b/web/src/api/docs.ts new file mode 100644 index 00000000..418be4de --- /dev/null +++ b/web/src/api/docs.ts @@ -0,0 +1,13 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { getJson } from './client'; +import type { IntegrationDocs } from './generated/IntegrationDocs'; + +/** + * The contract only changes with the build Observer runs. The server's side of it changes with + * each start and with the launch settings: whoever shows it reads it again then. + */ +export const integrationDocsQuery = queryOptions({ + queryKey: ['docs', 'integration'], + queryFn: ({ signal }) => getJson('/docs/integration', signal), +}); diff --git a/web/src/api/generated/BridgeExport.ts b/web/src/api/generated/BridgeExport.ts new file mode 100644 index 00000000..8f83bc31 --- /dev/null +++ b/web/src/api/generated/BridgeExport.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * An export of the bridge, as its script declares it. + */ +export type BridgeExport = { name: string, parameters: Array, }; diff --git a/web/src/api/generated/ExampleFile.ts b/web/src/api/generated/ExampleFile.ts new file mode 100644 index 00000000..065f7e65 --- /dev/null +++ b/web/src/api/generated/ExampleFile.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ExampleFile = { +/** + * From the contract's own folder, as its links name it: `observer_example/server.lua`. + */ +name: string, text: string, }; diff --git a/web/src/api/generated/IntegrationDocs.ts b/web/src/api/generated/IntegrationDocs.ts new file mode 100644 index 00000000..48297201 --- /dev/null +++ b/web/src/api/generated/IntegrationDocs.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BridgeExport } from "./BridgeExport"; +import type { ExampleFile } from "./ExampleFile"; +import type { IntegrationServer } from "./IntegrationServer"; + +/** + * `GET /api/docs/integration`. + */ +export type IntegrationDocs = { +/** + * The contract in Markdown, as this build ships it: the page is drawn from it. + */ +contract: string, contractVersion: number, +/** + * Every server event the bridge raises, as resources hear it: `observer:shuttingDown`. + */ +events: Array, exports: Array, +/** + * The example resource, file by file. + */ +example: Array, +/** + * Null for an account that does not see the server's state, which takes the dashboard's + * permission: the contract is anyone's to read, what the server does now is not. + */ +server: IntegrationServer | null, }; diff --git a/web/src/api/generated/IntegrationServer.ts b/web/src/api/generated/IntegrationServer.ts new file mode 100644 index 00000000..4df7ff91 --- /dev/null +++ b/web/src/api/generated/IntegrationServer.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What the server Observer runs does of the contract. + */ +export type IntegrationServer = { +/** + * The bridge's name in the server that runs now, drawn for its start. Null while no server + * runs. + */ +bridge: string | null, +/** + * That server is still starting: its bridge may not have reported yet. + */ +starting: boolean, +/** + * Whether the server that runs now raises txAdmin's events too. Null while no server runs. + */ +txadminEvents: boolean | null, +/** + * Whether the next start will, as the launch settings have it. Null until the server is set + * up. + */ +txadminEventsNext: boolean | null, }; diff --git a/web/src/components/CopyField.tsx b/web/src/components/CopyField.tsx index 68ea07aa..9d457a4d 100644 --- a/web/src/components/CopyField.tsx +++ b/web/src/components/CopyField.tsx @@ -1,8 +1,9 @@ import { Check, Copy } from 'lucide-react'; -import { useId, useRef, useState } from 'react'; +import { useId, useRef } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { cn } from '../lib/cn'; +import { useCopy } from '../lib/useCopy'; import { Button } from './Button'; import { inputClass } from './Field'; @@ -11,18 +12,7 @@ export function CopyField({ label, value }: { label: string; value: string }) { const { t } = useI18n(); const id = useId(); const input = useRef(null); - const [state, setState] = useState<'idle' | 'copied' | 'blocked'>('idle'); - - const copy = async () => { - try { - await navigator.clipboard.writeText(value); - setState('copied'); - } catch { - // No clipboard outside a secure context (plain HTTP on a LAN address): select it instead. - input.current?.select(); - setState('blocked'); - } - }; + const { state, copy } = useCopy({ kept: true }); return (
@@ -39,7 +29,15 @@ export function CopyField({ label, value }: { label: string; value: string }) { className={cn(inputClass, 'min-w-0 font-mono text-ui [font-variant-ligatures:none]')} onFocus={(event) => event.currentTarget.select()} /> -
diff --git a/web/src/components/CopyValue.tsx b/web/src/components/CopyValue.tsx index 9b43e566..3a2c3e3a 100644 --- a/web/src/components/CopyValue.tsx +++ b/web/src/components/CopyValue.tsx @@ -1,11 +1,9 @@ import { Check, Copy } from 'lucide-react'; -import { useEffect, useId, useState } from 'react'; +import { useId } from 'react'; import { useI18n } from '../i18n/I18nProvider'; import { cn } from '../lib/cn'; - -/** How long "Copied" stays under the value. */ -const COPIED_FOR = 2_000; +import { useCopy } from '../lib/useCopy'; /** * A long value shown short, in mono, and copied whole in one click where the browser allows it. @@ -14,23 +12,7 @@ const COPIED_FOR = 2_000; export function CopyValue({ value, shown, label }: { value: string; shown: string; label: string }) { const { t } = useI18n(); const id = useId(); - const [state, setState] = useState<'idle' | 'copied' | 'blocked'>('idle'); - useEffect(() => { - if (state !== 'copied') { - return; - } - const timer = setTimeout(() => setState('idle'), COPIED_FOR); - return () => clearTimeout(timer); - }, [state]); - const copy = async () => { - try { - await navigator.clipboard.writeText(value); - setState('copied'); - } catch { - // No clipboard outside a secure context (plain HTTP on a LAN address). - setState('blocked'); - } - }; + const { state, copy } = useCopy(); return ( + + + {state === 'copied' + ? t.docs.linkCopied + : state === 'blocked' + ? t.common.copyBlockedValue(address) + : ''} + + + ); +} + +/** The files a section's link leads to, each folded under its name. */ +function Files({ files }: { files: ExampleFile[] }) { + const folders = [...new Set(files.map(folderOf))]; + return folders.map((folder) => ( +
+ {files + .filter((file) => folderOf(file) === folder) + .map((file) => ( + + ))} +
+ )); +} + +/** A section of the contract: its text, the files it links to, and what the panel adds to it. */ +export function SectionCard({ + section, + model, + docs, +}: { + section: DocSection; + model: DocModel; + docs: IntegrationDocs; +}) { + const { t } = useI18n(); + const titleId = `${section.id}-title`; + const reading = useMemo( + () => ({ anchors: model.anchors, paths: new Set(docs.example.map(folderOf)), own: section.title }), + [model.anchors, docs.example, section.title], + ); + return ( + +
+ +

+ {section.title} +

+ + {(section.blocks.length > 0 || section.files.length > 0) && ( +
+ {section.exports.length > 0 && ( + // As the bridge's script declares them: the parameters are its own names. +

+ {t.docs.exports} + {section.exports.map(({ name, parameters }) => ( + + {name}({parameters.join(', ')}) + + ))} +

+ )} + + +
+ )} + +
+
+ ); +} + +/** + * An event, as a rule: when it comes, what it carries, what the contract adds, and a handler to + * copy. Another third-level heading of the contract is drawn the same way, without the handler. + */ +export function EntryCard({ + entry, + model, + docs, +}: { + entry: DocEntry; + model: DocModel; + docs: IntegrationDocs; +}) { + const { t } = useI18n(); + const titleId = `${entry.id}-title`; + const reading = useMemo( + () => ({ anchors: model.anchors, paths: new Set(docs.example.map(folderOf)), own: entry.title }), + [model.anchors, docs.example, entry.title], + ); + const handler = useMemo( + () => handlerOf(entry, docs.example.map((file) => file.text).join('\n')), + [entry, docs.example], + ); + // What comes before the fields introduces them; the rest follows them. + const at = entry.blocks.findIndex((block) => block.kind === 'table'); + const fields = entry.blocks[at]; + const intro = at < 0 ? [] : entry.blocks.slice(0, at); + const rest = at < 0 ? entry.blocks : entry.blocks.slice(at + 1); + return ( + +
+ +

+ {entry.title} +

+ {entry.event && {t.docs.event}} + + {(entry.when !== null || intro.length > 0) && ( +
+ {entry.when !== null && ( +

+ +

+ )} + +
+ )} + {fields?.kind === 'table' && } + {(rest.length > 0 || entry.event) && ( +
+ + {entry.event && ( + + )} +
+ )} + +
+
+ ); +} diff --git a/web/src/docs/CodeBlock.tsx b/web/src/docs/CodeBlock.tsx new file mode 100644 index 00000000..b2622cf6 --- /dev/null +++ b/web/src/docs/CodeBlock.tsx @@ -0,0 +1,127 @@ +import { Check, ChevronRight, Copy } from 'lucide-react'; +import { useId, useMemo, useRef, useState } from 'react'; + +import { Button } from '../components/Button'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { useCopy } from '../lib/useCopy'; +import { luaTokens, type LuaKind } from './lua'; + +const INK: Record = { + comment: 'text-term-dim', + string: 'text-term-green', + keyword: 'text-term-magenta', + number: 'text-term-yellow', + plain: '', +}; + +/** + * Code to copy, on the console's ground: a line never folds, the block scrolls. `name` says what the + * code is, for its copy button. A `folded` block is a file: it opens on its name. Lua is coloured + * unless `lua` says the code is something else. + */ +export function CodeBlock({ + code, + label, + name, + folded = false, + lua = true, +}: { + code: string; + label: string; + name: string; + folded?: boolean; + lua?: boolean; +}) { + const { t } = useI18n(); + const id = useId(); + const { state, copy } = useCopy(); + const [open, setOpen] = useState(!folded); + const pre = useRef(null); + const tokens = useMemo( + () => (lua ? luaTokens(code) : [{ at: 0, text: code, kind: 'plain' as const }]), + [code, lua], + ); + + const copyCode = async () => { + const text = pre.current; + // Where the browser refuses, the code is selected instead, so the keyboard can copy it. + if (!(await copy(code)) && text !== null) { + const range = document.createRange(); + range.selectNodeContents(text); + const selection = getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }; + + return ( +
+
+ {folded ? ( + + ) : ( + {label} + )} + +
+ + + {state === 'copied' ? t.common.copied : state === 'blocked' ? t.common.copyBlocked : ''} + +
+ ); +} diff --git a/web/src/docs/Notes.tsx b/web/src/docs/Notes.tsx new file mode 100644 index 00000000..4372a69a --- /dev/null +++ b/web/src/docs/Notes.tsx @@ -0,0 +1,116 @@ +import { Link } from '@tanstack/react-router'; +import { AppWindow, Server, type LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { IntegrationServer } from '../api/generated/IntegrationServer'; +import { Permission } from '../api/generated/Permission'; +import { allows, useMe } from '../auth/me'; +import { useI18n } from '../i18n/I18nProvider'; +import { anchorOf as settingAnchor } from '../settings/catalog'; + +/** + * The places of the contract the panel adds a word to, by their address on the page: where the + * effect of a resource's code shows in the panel, and what this server does. A test holds each + * address to the contract, so that a heading renamed there does not lose its note in silence. + */ +export const NOTED = ['player-labels', 'observerstartchanges', 'bans', 'txadmins-events'] as const; + +export const link = + 'font-medium text-ink-2 underline decoration-line-strong underline-offset-3 hover:text-ink'; + +/** The link to the launch setting that sends txAdmin's events, for the owner, who alone opens it. */ +export function LaunchSettingsLink() { + const { t } = useI18n(); + return useMe().isOwner ? ( + + {t.docs.server.launchSettings} + + ) : null; +} + +/** What the panel says under a section of the contract, if it has a word for it. */ +export function SectionNote({ id, server }: { id: string; server: IntegrationServer | null }) { + const { t } = useI18n(); + const me = useMe(); + const words = t.docs.notes; + // A page the account does not open is named, not linked. + const page = (to: '/players' | '/changes' | '/bans', name: string, permission: bigint) => + allows(me, permission) ? ( + + {name} + + ) : ( + name + ); + + let icon: LucideIcon = AppWindow; + let lead = words.panel; + let text: ReactNode; + switch (id as (typeof NOTED)[number]) { + case 'player-labels': + text = words.labels(page('/players', t.nav.players, Permission.PlayersView)); + break; + case 'observerstartchanges': + text = words.startChanges(page('/changes', t.nav.changes, Permission.ChangesView)); + break; + case 'bans': + text = words.bans( + page('/bans', t.nav.bans, Permission.BansView), + allows(me, Permission.SettingsView) ? ( + + {words.refusalMessage} + + ) : ( + words.refusalMessage + ), + ); + break; + case 'txadmins-events': { + // An account that is not told what the server does reads the contract alone. + if (server === null) { + return null; + } + const said = + server.txadminEvents !== null + ? server.txadminEvents + ? words.txadminOn + : words.txadminOff + : server.txadminEventsNext !== null + ? words.txadminStopped(server.txadminEventsNext) + : null; + if (said === null) { + return null; + } + icon = Server; + lead = words.server; + text = ( + <> + {said} + + ); + break; + } + default: + return null; + } + const Icon = icon; + return ( +
+
+ ); +} diff --git a/web/src/docs/Prose.tsx b/web/src/docs/Prose.tsx new file mode 100644 index 00000000..0b4ab9d9 --- /dev/null +++ b/web/src/docs/Prose.tsx @@ -0,0 +1,257 @@ +import { Fragment, createContext, use, type ReactNode } from 'react'; + +import { table } from '../components/table'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { parseInline, plainText, type Block, type Inline } from '../lib/markdown'; +import { Anchor } from './Anchor'; +import { CodeBlock } from './CodeBlock'; +import { filesAnchor } from './model'; + +/** What the text of the documentation is read against. */ +export type Reading = { + /** Where a name written in code leads on the page. */ + anchors: Map; + /** The paths whose files the page shows. */ + paths: Set; + /** The title of the section or entry the text belongs to: its own name is not a link to itself. */ + own: string; +}; + +export const ReadingContext = createContext({ anchors: new Map(), paths: new Set(), own: '' }); + +const link = + 'font-medium text-ink underline decoration-line-strong underline-offset-3 hover:decoration-ink-3'; + +/** A long name folds after a colon before it folds anywhere else. */ +const foldable = (text: string) => { + let at = 0; + return text.split(/(?<=:)(?=\w)/).map((piece) => { + const start = at; + at += piece.length; + return ( + + {start > 0 && } + {piece} + + ); + }); +}; + +function Nodes({ nodes, folding }: { nodes: Inline[]; folding: boolean }): ReactNode { + const { anchors, paths, own } = use(ReadingContext); + return nodes.map((node) => { + switch (node.kind) { + case 'text': + return {node.text}; + case 'code': { + const code = ( + + {folding ? foldable(node.text) : node.text} + + ); + const anchor = node.text === own ? undefined : anchors.get(node.text); + return anchor === undefined ? ( + {code} + ) : ( + + {code} + + ); + } + case 'strong': + return ( + + + + ); + case 'em': + return ( + + + + ); + case 'link': + return ( + + + + ); + case 'local': { + const hash = node.target.startsWith('#') + ? node.target.slice(1) + : paths.has(node.target) + ? filesAnchor(node.target) + : null; + // A path the page shows nothing of leads nowhere: its words stay. + return hash === null ? ( + + + + ) : ( + + + + ); + } + } + }); +} + +/** A line of the contract: its code, its emphasis and its links, drawn as elements, never as HTML. */ +export function Text({ text, folding = false }: { text: string; folding?: boolean }) { + return ; +} + +/** Longer than this, a table's second column no longer rides beside the first on a phone. */ +const BESIDE_CHARS = 24; +const short = (text: string) => plainText(parseInline(text)).length <= BESIDE_CHARS; + +/** + * A table of the contract, to the card's edges. On a phone each row is a block: its first cell is + * its title, a short second one rides beside it, and the cells after them are named by their column + * once there are several. + */ +export function DocTable({ block }: { block: Extract }) { + const beside = block.head.length >= 3 && block.rows.every((row) => short(row.cells[1]?.text ?? '')); + const named = block.head.length - (beside ? 2 : 1) > 1; + return ( +
+ + + + {block.head.map((cell) => ( + + ))} + + + + {block.rows.map((row) => ( + td]:border-b-0', + beside && + 'max-md:grid max-md:grid-cols-[auto_1fr] max-md:items-baseline max-md:gap-x-2.5', + )} + > + {row.cells.map((cell, index) => { + const key = index === 0 && /^`[^`]+`$/.test(cell.text); + const riding = beside && index === 1; + const label = + named && index >= (beside ? 2 : 1) ? block.head[index]?.text : undefined; + return ( + + ); + })} + + ))} + +
+ +
0 && !riding && 'max-md:mt-1', + beside && index >= 2 && 'max-md:col-span-2', + )} + > + {label !== undefined && cell.text !== '' && ( + + + + )} + +
+
+ ); +} + +/** + * Blocks of the contract inside a card's body: paragraphs, lists, code to copy and tables that run + * to the card's edges. `codeName` says what a block of code is, for its copy button. + */ +export function Prose({ blocks, codeName }: { blocks: Block[]; codeName: string }) { + const { t } = useI18n(); + return blocks.map((block) => { + switch (block.kind) { + case 'paragraph': + return ( +

+ +

+ ); + case 'list': { + const items = block.items.map((item) => ( +
  • + +
  • + )); + const className = 'flex max-w-[68ch] flex-col gap-1.5 pl-5 text-pretty marker:text-ink-3'; + return block.ordered ? ( +
      + {items} +
    + ) : ( +
      + {items} +
    + ); + } + case 'code': + return ( + + ); + case 'table': + return ( + // The last thing in a card runs to its foot. +
    + +
    + ); + case 'quote': + return ( +
    + +
    + ); + case 'heading': + return ( +

    + +

    + ); + case 'rule': + return
    ; + } + }); +} diff --git a/web/src/docs/ThisServer.tsx b/web/src/docs/ThisServer.tsx new file mode 100644 index 00000000..78e27bc1 --- /dev/null +++ b/web/src/docs/ThisServer.tsx @@ -0,0 +1,112 @@ +import { Server } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { IntegrationServer } from '../api/generated/IntegrationServer'; +import { Card } from '../components/Card'; +import { useI18n } from '../i18n/I18nProvider'; +import { useServerStatus } from '../server/context'; +import { ANCHORED } from './Cards'; +import { LaunchSettingsLink } from './Notes'; + +/** Where the card stands on the page, for the contents and for links. */ +export const THIS_SERVER = 'this-server'; + +function Fact({ term, value, children }: { term: string; value: ReactNode; children: ReactNode }) { + return ( +
    +
    {term}
    +
    + {value} + {children} +
    +
    + ); +} + +/** + * What the server Observer runs does of the contract, now: the version its bridge speaks, the name + * the bridge took for this start, and whether txAdmin's events are raised too. + */ +export function ThisServer({ + contractVersion, + server, +}: { + contractVersion: number; + server: IntegrationServer; +}) { + const { t, format } = useI18n(); + const words = t.docs.server; + const { status } = useServerStatus(); + const { bridge, starting, txadminEvents, txadminEventsNext: next } = server; + const startedAt = status?.startedAt ?? null; + const meta = + bridge === null + ? words.stopped + : startedAt === null + ? words.running + : words.startedAt(format.timestamp(startedAt, { seconds: false })); + + let txadmin: { value: string; about: string }; + if (txadminEvents !== null) { + const about = txadminEvents ? words.txadminOn : words.txadminOff; + txadmin = { + value: txadminEvents ? words.on : words.off, + // The setting was saved since this start: the next one goes by it. + about: next !== null && next !== txadminEvents ? `${about} ${words.txadminNext(next)}` : about, + }; + } else if (next !== null) { + txadmin = { value: next ? words.on : words.off, about: words.txadminFromNext }; + } else { + txadmin = { value: words.notSet, about: words.notSetAbout }; + } + + return ( +
    + +
    + + {words.versionAbout} + + {bridge === null ? ( + + + ) : ( + + + )} + + {txadmin.about} + +
    +
    +
    + ); +} diff --git a/web/src/docs/Toc.tsx b/web/src/docs/Toc.tsx new file mode 100644 index 00000000..7dd40888 --- /dev/null +++ b/web/src/docs/Toc.tsx @@ -0,0 +1,197 @@ +import { ChevronDown } from 'lucide-react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; + +import { buttonVariants } from '../components/Button'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { Anchor } from './Anchor'; + +export type TocItem = { + key: string; + /** Where it leads on the page. */ + id: string; + label: string; + /** An event or an export, under its section. */ + sub: boolean; +}; + +export type TocGroup = { label: string; items: TocItem[] }; + +/** How far under where a link brings a section its top may be for the section to count as reached. */ +const SLACK = 24; + +/** + * The place of the page that is under the reader's eyes: the last of `ids` whose top went past + * where a link to it would bring it, the last one once the page is at its end. + */ +export const useReached = (ids: string[]): string | null => { + const [reached, setReached] = useState(null); + useEffect(() => { + const read = () => { + let found = ids[0] ?? null; + for (const id of ids) { + const element = document.getElementById(id); + if (element === null) { + continue; + } + const line = (Number.parseFloat(getComputedStyle(element).scrollMarginTop) || 0) + SLACK; + if (element.getBoundingClientRect().top <= line) { + found = id; + } + } + const page = document.documentElement; + if ( + ids.length > 0 && + page.scrollTop > 0 && + innerHeight + page.scrollTop >= page.scrollHeight - 2 + ) { + found = ids.at(-1) ?? found; + } + setReached(found); + }; + read(); + addEventListener('scroll', read, { passive: true }); + addEventListener('resize', read); + return () => { + removeEventListener('scroll', read); + removeEventListener('resize', read); + }; + }, [ids]); + return reached; +}; + +const entry = + 'flex min-h-8 items-center rounded-[9px] px-3 text-nav font-medium text-ink-2 transition-colors hover:bg-hover hover:text-ink focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent aria-[current=location]:bg-active aria-[current=location]:text-ink max-md:min-h-11'; +const subEntry = + 'min-h-7 pl-6 font-mono text-meta font-normal text-ink-3 [font-variant-ligatures:none] aria-[current=location]:text-ink'; + +function Entries({ + groups, + reached, + onNavigate, +}: { + groups: TocGroup[]; + reached: string | null; + onNavigate?: () => void; +}) { + // Exports share their section's place: the section alone is marked. + const current = groups.flatMap((group) => group.items).find((item) => item.id === reached)?.key; + return groups.map((group) => ( +
    +

    + {group.label} +

    + {group.items.map((item) => ( + + {item.label} + + ))} +
    + )); +} + +/** + * The documentation's contents: beside the page on a wide screen, where it follows the reader, and + * under one button on a phone, which names the section being read. + */ +export function Toc({ groups }: { groups: TocGroup[] }) { + const { t } = useI18n(); + const listId = useId(); + const ids = useMemo( + () => [...new Set(groups.flatMap((group) => group.items.map((item) => item.id)))], + [groups], + ); + const reached = useReached(ids); + const [open, setOpen] = useState(false); + const side = useRef(null); + const phone = useRef(null); + const items = groups.flatMap((group) => group.items); + const reading = + items.find((item) => item.id === reached && !item.sub) ?? items.find((item) => item.id === reached); + + // A long contents list scrolls by itself: the entry being read stays in sight. + useEffect(() => { + const list = side.current; + const marked = reached === null ? null : list?.querySelector('[aria-current="location"]'); + if (list === null || marked === null || marked === undefined) { + return; + } + const [box, row] = [list.getBoundingClientRect(), marked.getBoundingClientRect()]; + if (row.top < box.top) { + list.scrollTop -= box.top - row.top + 8; + } else if (row.bottom > box.bottom) { + list.scrollTop += row.bottom - box.bottom + 8; + } + }, [reached]); + + useEffect(() => { + if (!open) { + return; + } + const close = (event: Event) => { + if ( + event instanceof KeyboardEvent + ? event.key === 'Escape' + : !phone.current?.contains(event.target as Node) + ) { + setOpen(false); + } + }; + addEventListener('keydown', close); + addEventListener('pointerdown', close); + return () => { + removeEventListener('keydown', close); + removeEventListener('pointerdown', close); + }; + }, [open]); + + return ( + <> + +
    + + +
    + + ); +} diff --git a/web/src/docs/fixtures.ts b/web/src/docs/fixtures.ts new file mode 100644 index 00000000..f484b644 --- /dev/null +++ b/web/src/docs/fixtures.ts @@ -0,0 +1,41 @@ +// The contract, the bridge and the example as the repository holds them, for the tests: what the +// API serves from the binary, read here from the same files. + +import bridge from '../../../resource/observer/server.lua?raw'; +import contract from '../../../resource/CONTRACT.md?raw'; +import exampleManifest from '../../../resource/observer_example/fxmanifest.lua?raw'; +import exampleScript from '../../../resource/observer_example/server.lua?raw'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import type { IntegrationServer } from '../api/generated/IntegrationServer'; + +const named = /local EVENTS = \{\n([\s\S]*?)\n\}/.exec(bridge)?.[1] ?? ''; + +/** + * What `GET /api/docs/integration` answers for a server that does not run, or for an account that + * is not told what the server does when `server` is null. + */ +export const integrationDocs = (server: Partial | null = {}): IntegrationDocs => ({ + contract, + contractVersion: Number(/local CONTRACT_VERSION = (\d+)/.exec(bridge)?.[1]), + events: [ + ...new Set([ + ...[...named.matchAll(/^\s*(\w+) = true,$/gm)].map((match) => `observer:${match[1] ?? ''}`), + ...[...bridge.matchAll(/TriggerEvent\('(observer:\w+)'/g)].map((match) => match[1] ?? ''), + ]), + ], + exports: [...bridge.matchAll(/^exports\('(\w+)', function\(([^)]*)\)/gm)].map((match) => ({ + name: match[1] ?? '', + parameters: (match[2] ?? '') + .split(',') + .map((parameter) => parameter.trim()) + .filter(Boolean), + })), + example: [ + { name: 'observer_example/fxmanifest.lua', text: exampleManifest }, + { name: 'observer_example/server.lua', text: exampleScript }, + ], + server: + server === null + ? null + : { bridge: null, starting: false, txadminEvents: null, txadminEventsNext: null, ...server }, +}); diff --git a/web/src/docs/lua.test.ts b/web/src/docs/lua.test.ts new file mode 100644 index 00000000..da7d4a5d --- /dev/null +++ b/web/src/docs/lua.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { integrationDocs } from './fixtures'; +import { luaTokens } from './lua'; + +describe('luaTokens', () => { + it('tells comments, strings, keywords and numbers from the rest', () => { + const code = "local bridge = GetConvar('observer_bridge', '') -- empty, or 4"; + + expect(luaTokens(code).map(({ text, kind }) => [text, kind])).toEqual([ + ['local', 'keyword'], + [' bridge = GetConvar(', 'plain'], + ["'observer_bridge'", 'string'], + [', ', 'plain'], + ["''", 'string'], + [') ', 'plain'], + ['-- empty, or 4', 'comment'], + ]); + }); + + it('takes a keyword inside a name or a string for what it is', () => { + expect( + luaTokens("endsAt = 'in the end' and 30 or nil").map(({ text, kind }) => [text, kind]), + ).toEqual([ + ['endsAt = ', 'plain'], + ["'in the end'", 'string'], + [' ', 'plain'], + ['and', 'keyword'], + [' ', 'plain'], + ['30', 'number'], + [' ', 'plain'], + ['or', 'keyword'], + [' ', 'plain'], + ['nil', 'keyword'], + ]); + }); + + it('gives back every character of the example resource, in order', () => { + for (const { text } of integrationDocs().example) { + const tokens = luaTokens(text); + + expect(tokens.map((token) => token.text).join('')).toBe(text); + expect(new Set(tokens.map((token) => token.at)).size).toBe(tokens.length); + } + }); +}); diff --git a/web/src/docs/lua.ts b/web/src/docs/lua.ts new file mode 100644 index 00000000..ba8c633c --- /dev/null +++ b/web/src/docs/lua.ts @@ -0,0 +1,34 @@ +export type LuaKind = 'comment' | 'string' | 'keyword' | 'number' | 'plain'; +/** A piece of Lua, keyed by where it starts in its text. */ +export type LuaToken = { at: number; text: string; kind: LuaKind }; + +// A comment to the end of its line, a quoted string, a keyword, a number. Long brackets are left +// plain: the contract's examples have none. +const PIECE = + /(--.*)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*")|\b(and|break|do|else|elseif|end|false|for|function|goto|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b|\b(\d+(?:\.\d+)?)\b/g; + +/** Lua as the documentation colours it: comments, strings, keywords and numbers. */ +export const luaTokens = (code: string): LuaToken[] => { + const tokens: LuaToken[] = []; + let at = 0; + for (const match of code.matchAll(PIECE)) { + if (match.index > at) { + tokens.push({ at, text: code.slice(at, match.index), kind: 'plain' }); + } + const [text, comment, string, keyword] = match; + const kind = + comment !== undefined + ? 'comment' + : string !== undefined + ? 'string' + : keyword !== undefined + ? 'keyword' + : 'number'; + tokens.push({ at: match.index, text, kind }); + at = match.index + text.length; + } + if (at < code.length) { + tokens.push({ at, text: code.slice(at), kind: 'plain' }); + } + return tokens; +}; diff --git a/web/src/docs/model.test.ts b/web/src/docs/model.test.ts new file mode 100644 index 00000000..0e2a2bbb --- /dev/null +++ b/web/src/docs/model.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBlocks, parseInline, type Inline } from '../lib/markdown'; +import { integrationDocs } from './fixtures'; +import { anchorOf, buildDocs, docIndex, fieldsOf, filesAnchor, handlerOf } from './model'; + +const docs = integrationDocs(); +const model = buildDocs(docs); +const entries = model.sections.flatMap((section) => section.entries); +const script = docs.example.find((file) => file.name.endsWith('server.lua'))?.text ?? ''; + +describe('anchorOf', () => { + it('makes the address GitHub makes of a heading', () => { + expect(anchorOf('Player labels')).toBe('player-labels'); + expect(anchorOf("txAdmin's events")).toBe('txadmins-events'); + expect(anchorOf('`observer:restartScheduled`')).toBe('observerrestartscheduled'); + expect(anchorOf('Not in version 4')).toBe('not-in-version-4'); + }); +}); + +describe('buildDocs, on the contract as the repository holds it', () => { + it('gives every heading of the contract a place, each with its own address', () => { + const headings = parseBlocks(docs.contract).filter((block) => block.kind === 'heading'); + const ids = [...model.sections.map((section) => section.id), ...entries.map((entry) => entry.id)]; + + expect(model.sections).toHaveLength(headings.filter((heading) => heading.level === 2).length); + expect(entries).toHaveLength(headings.filter((heading) => heading.level === 3).length); + expect(new Set(ids).size).toBe(ids.length); + expect(model.lead).not.toHaveLength(0); + }); + + it('knows every event the bridge raises, when it comes and what it carries', () => { + expect(docs.events).not.toHaveLength(0); + for (const event of docs.events) { + const entry = entries.find((candidate) => candidate.title === event); + + expect(entry, event).toMatchObject({ event: true }); + expect(entry?.when, event).toBeTruthy(); + expect(entry === undefined ? [] : fieldsOf(entry), event).not.toHaveLength(0); + expect(model.anchors.get(event), event).toBe(entry?.id); + } + }); + + it('places every export of the bridge in the one section that shows it called', () => { + expect(docs.exports).not.toHaveLength(0); + for (const { name } of docs.exports) { + const holders = model.sections.filter((section) => + section.exports.some((placed) => placed.name === name), + ); + + expect(holders, name).toHaveLength(1); + } + }); + + it('leads every link of the contract somewhere on the page', () => { + const locals = (nodes: Inline[]): string[] => + nodes.flatMap((node) => + node.kind === 'local' ? [node.target] : 'children' in node ? locals(node.children) : [], + ); + const targets = parseBlocks(docs.contract).flatMap((block) => { + const texts = + block.kind === 'paragraph' + ? [block.text] + : block.kind === 'list' + ? block.items.map((item) => item.text) + : block.kind === 'table' + ? block.rows.flatMap((row) => row.cells.map((cell) => cell.text)) + : []; + return texts.flatMap((text) => locals(parseInline(text))); + }); + const ids = new Set([ + ...model.sections.map((section) => section.id), + ...entries.map((entry) => entry.id), + ]); + const shown = model.sections.flatMap((section) => section.files.map((file) => file.name)); + + expect(targets).not.toHaveLength(0); + for (const target of targets) { + if (target.startsWith('#')) { + expect(ids.has(target.slice(1)), target).toBe(true); + } else { + expect( + shown.some((name) => name.startsWith(`${target}/`)), + target, + ).toBe(true); + } + } + expect(filesAnchor('observer_example')).toBe('files-observer_example'); + }); +}); + +describe('handlerOf', () => { + it('gives each event a handler that names its fields and says what the example says', () => { + for (const entry of entries.filter((candidate) => candidate.event)) { + const handler = handlerOf(entry, script); + const lines = handler.split('\n'); + + expect(lines[0], entry.title).toBe(`AddEventHandler('${entry.title}', function(payload)`); + expect(lines.at(-1), entry.title).toBe('end)'); + for (const field of fieldsOf(entry)) { + expect(handler, entry.title).toContain(`payload.${field}`); + } + // The example's own comment for this event, folded. + expect(lines.length, entry.title).toBeGreaterThan(3); + for (const line of lines.slice(1, -1)) { + expect(line.startsWith(' -- '), line).toBe(true); + expect(line.length, line).toBeLessThanOrEqual(72); + } + } + }); + + it('stands without the example: its fields alone', () => { + for (const entry of entries.filter((candidate) => candidate.event)) { + const lines = handlerOf(entry, '').split('\n'); + + expect(lines[0], entry.title).toBe(`AddEventHandler('${entry.title}', function(payload)`); + expect(lines.at(-1), entry.title).toBe('end)'); + expect( + lines + .slice(1, -1) + .map((line) => line.replace(/^ {4}-- /, '')) + .join(' '), + entry.title, + ).toBe( + fieldsOf(entry) + .map((field) => `payload.${field}`) + .join(', '), + ); + } + }); +}); + +describe('docIndex', () => { + it('lists the sections, the events, the exports and the names txAdmin gives its events', () => { + const index = docIndex(model); + const of = (kind: string) => index.filter((match) => match.kind === kind).map((match) => match.label); + + expect(of('section')).toContain('Player labels'); + // In the contract's order, which need not be the order the bridge raises them in. + expect(of('event').toSorted()).toEqual(docs.events.toSorted()); + expect(of('export').toSorted()).toEqual(docs.exports.map((item) => item.name).toSorted()); + expect(of('txadmin')).toContain('txAdmin:events:serverShuttingDown'); + expect(index.find((match) => match.label === 'setPlayerLabel')?.id).toBe('player-labels'); + }); +}); diff --git a/web/src/docs/model.ts b/web/src/docs/model.ts new file mode 100644 index 00000000..e16a1e63 --- /dev/null +++ b/web/src/docs/model.ts @@ -0,0 +1,239 @@ +// How the Documentation page reads the integration contract: its sections, the events and exports +// among them, and where a name written in the text leads. The contract is the binary's own +// `resource/CONTRACT.md`; nothing of its text is written again here. + +import type { BridgeExport } from '../api/generated/BridgeExport'; +import type { ExampleFile } from '../api/generated/ExampleFile'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import { parseBlocks, parseInline, plainText, type Block, type Inline } from '../lib/markdown'; + +/** A third-level heading of the contract: an event, as a rule. */ +export type DocEntry = { + id: string; + title: string; + /** The bridge raises it. */ + event: boolean; + /** When it comes, in Markdown, as the first table that lists it says. */ + when: string | null; + blocks: Block[]; +}; + +/** A second-level heading of the contract and what it holds. */ +export type DocSection = { + id: string; + title: string; + blocks: Block[]; + entries: DocEntry[]; + /** The bridge's exports this section shows called. */ + exports: BridgeExport[]; + /** The files a link of this section leads to, such as the example resource's. */ + files: ExampleFile[]; +}; + +export type DocModel = { + /** What the contract says before its first section. */ + lead: Block[]; + sections: DocSection[]; + /** Where a name leads when the text writes it in code: a section's or an entry's title. */ + anchors: Map; +}; + +const text = (markdown: string) => plainText(parseInline(markdown)); + +/** + * The address of a heading as GitHub makes it, so that the contract's own links lead to the same + * places in the panel: `Player labels` is `player-labels`. + */ +export const anchorOf = (title: string) => + text(title) + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .trim() + .replace(/\s/g, '-'); + +/** Where the files of a path the contract links to are shown. */ +export const filesAnchor = (path: string) => `files-${anchorOf(path)}`; + +const inlinesOf = (block: Block): string[] => { + switch (block.kind) { + case 'heading': + case 'paragraph': + case 'quote': + return [block.text]; + case 'list': + return block.items.map((item) => item.text); + case 'table': + return [...block.head, ...block.rows.flatMap((row) => row.cells)].map((cell) => cell.text); + case 'code': + case 'rule': + return []; + } +}; + +/** Everything a block says, its code included. */ +const said = (block: Block) => (block.kind === 'code' ? block.text : inlinesOf(block).join('\n')); + +const localTargets = (nodes: Inline[]): string[] => + nodes.flatMap((node) => + node.kind === 'local' + ? [node.target, ...localTargets(node.children)] + : 'children' in node + ? localTargets(node.children) + : [], + ); + +export function buildDocs({ + contract, + events, + exports, + example, +}: Pick): DocModel { + const blocks = parseBlocks(contract); + const lead: Block[] = []; + const sections: DocSection[] = []; + const taken = new Map(); + // A title met again takes a number, as on GitHub. + const unique = (id: string) => { + const seen = taken.get(id) ?? 0; + taken.set(id, seen + 1); + return seen === 0 ? id : `${id}-${seen}`; + }; + const when = new Map(); + for (const block of blocks) { + if (block.kind !== 'table') { + continue; + } + for (const { cells } of block.rows) { + const name = text(cells[0]?.text ?? ''); + if (events.includes(name) && !when.has(name)) { + when.set(name, cells[1]?.text ?? ''); + } + } + } + + for (const block of blocks) { + if (block.kind === 'heading' && block.level === 1) { + continue; + } + if (block.kind === 'heading' && block.level === 2) { + const title = text(block.text); + sections.push({ + id: unique(anchorOf(title)), + title, + blocks: [], + entries: [], + exports: [], + files: [], + }); + continue; + } + const section = sections.at(-1); + if (section === undefined) { + lead.push(block); + } else if (block.kind === 'heading' && block.level === 3) { + const title = text(block.text); + section.entries.push({ + id: unique(anchorOf(title)), + title, + event: events.includes(title), + when: when.get(title) ?? null, + blocks: [], + }); + } else { + (section.entries.at(-1) ?? section).blocks.push(block); + } + } + + const anchors = new Map(); + const placed = new Set(); + for (const section of sections) { + const all = [...section.blocks, ...section.entries.flatMap((entry) => entry.blocks)]; + const whole = all.map(said).join('\n'); + // An export belongs to the first section that shows it called. + section.exports = exports.filter(({ name }) => !placed.has(name) && whole.includes(`]:${name}(`)); + for (const { name } of section.exports) { + placed.add(name); + } + const linked = new Set(all.flatMap(inlinesOf).flatMap((inline) => localTargets(parseInline(inline)))); + section.files = example.filter((file) => + [...linked].some((path) => !path.startsWith('#') && file.name.startsWith(`${path}/`)), + ); + anchors.set(section.title, section.id); + for (const entry of section.entries) { + anchors.set(entry.title, entry.id); + } + } + return { lead, sections, anchors }; +} + +/** The fields of an event, as the first table of its section names them. */ +export const fieldsOf = (entry: DocEntry): string[] => { + const table = entry.blocks.find((block) => block.kind === 'table'); + return table?.kind === 'table' ? table.rows.map((row) => text(row.cells[0]?.text ?? '')) : []; +}; + +/** The widest a line of a handler runs, so that it fits its block without scrolling. */ +const HANDLER_WIDTH = 72; +const COMMENT = ' --'; + +const folded = (words: string[]): string[] => { + const lines: string[] = []; + let line = COMMENT; + for (const word of words) { + if (line !== COMMENT && `${line} ${word}`.length > HANDLER_WIDTH) { + lines.push(line); + line = COMMENT; + } + line += ` ${word}`; + } + return line === COMMENT ? lines : [...lines, line]; +}; + +/** + * A handler to copy for an event: its fields as its table names them, then what the example + * resource says goes there, in its own words. + */ +export const handlerOf = (entry: DocEntry, example: string): string => { + const fields = fieldsOf(entry).map((field) => `payload.${field}`); + const lines = folded(fields.map((field, index) => (index < fields.length - 1 ? `${field},` : field))); + const start = example.indexOf(`AddEventHandler('${entry.title}'`); + if (start >= 0) { + const end = example.indexOf('\nend)', start); + for (const row of example.slice(start, end < 0 ? undefined : end).split('\n')) { + const comment = /^\s*--\s*(.*)$/.exec(row); + if (comment !== null) { + lines.push(...folded((comment[1] ?? '').split(' ').filter(Boolean))); + } + } + } + return [`AddEventHandler('${entry.title}', function(payload)`, ...lines, 'end)'].join('\n'); +}; + +export type DocMatch = { + /** Where it is on the page. */ + id: string; + label: string; + kind: 'section' | 'event' | 'export' | 'txadmin'; +}; + +const TXADMIN_EVENT = /\btxAdmin:events:\w+/g; + +/** + * What the search finds in the documentation: its sections, each event, each export, and txAdmin's + * events by their own names. + */ +export const docIndex = (model: DocModel): DocMatch[] => + model.sections.flatMap((section) => { + const all = [...section.blocks, ...section.entries.flatMap((entry) => entry.blocks)]; + const txadmin = new Set(all.flatMap((block) => said(block).match(TXADMIN_EVENT) ?? [])); + return [ + { id: section.id, label: section.title, kind: 'section' as const }, + ...section.entries.map((entry) => ({ + id: entry.id, + label: entry.title, + kind: entry.event ? ('event' as const) : ('section' as const), + })), + ...section.exports.map(({ name }) => ({ id: section.id, label: name, kind: 'export' as const })), + ...[...txadmin].map((name) => ({ id: section.id, label: name, kind: 'txadmin' as const })), + ]; + }); diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index ecb11c24..3026ab9a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -103,6 +103,7 @@ import type { DestinationKind } from '../api/backups'; import type { ResourceVerb } from '../api/resources'; import { parsePermissions, permissionKeys, type PermissionKey } from '../auth/permissions'; import type { InvisibleKind } from '../changes/invisible'; +import type { DocMatch } from '../docs/model'; import type { LinkState } from '../live/realtime'; import type { Filter as ResourceFilter } from '../resources/list'; import type { Applies as SettingApplies, CategoryGroup, CategoryId, SettingId } from '../settings/catalog'; @@ -480,6 +481,7 @@ export const en = { debugBags: 'State bags', debugHttp: 'HTTP requests', maintenance: 'Maintenance', + docs: 'Documentation', settings: 'Settings', schedule: 'Restarts', artifacts: 'FXServer builds', @@ -574,10 +576,17 @@ export const en = { }, palette: { label: 'Go to a page, a setting or a player', - placeholder: 'A page, a setting, a player online', + placeholder: 'A page, a setting, an event, a player online', results: 'Results', page: 'Page', tool: 'Tool', + doc: (kind: string) => `Documentation · ${kind}`, + docKinds: { + section: 'Section', + event: 'Event', + export: 'Export', + txadmin: "txAdmin's event", + } satisfies Record, setting: (category: string) => `Setting · ${category}`, player: (netId: number) => `Player online · #${netId}`, none: 'Nothing matches.', @@ -6240,6 +6249,83 @@ export const en = { missing: 'This build carries no licence list. cargo xtask web and cargo xtask dist write it.', loadFailed: 'The licence list could not be loaded.', }, + // The contract itself is not here: the page draws the text this build of Observer ships, as it + // draws a licence or a release's notes. These are the panel's own words around it. + docs: { + title: 'Documentation', + gloss: "for your server's resources", + find: 'Find an event or an export', + toc: 'On this page', + contract: 'The contract', + copyLink: (section: string) => `Copy the link to ${section}`, + linkCopied: 'Link copied', + event: 'Event', + exports: 'Exports', + lua: 'Lua', + copyNamed: (what: string) => `Copy ${what}`, + handlerOf: (event: string) => `the handler for ${event}`, + codeOf: (section: string) => `the code of ${section}`, + lines: (value: number) => count(value, { one: 'line', other: 'lines' }), + loading: 'Reading the documentation', + failed: 'The documentation could not be read.', + server: { + title: 'This server', + startedAt: (when: string) => `FXServer started ${when}`, + running: 'FXServer is running', + stopped: 'FXServer is stopped', + contract: 'Contract', + version: (version: number) => `Version ${formatNumber('en', version)}`, + versionAbout: "What this Observer's bridge speaks.", + bridge: 'Bridge', + bridgeRunning: 'Running', + bridgeStarting: 'Starting', + bridgeName: (name: ReactNode): ReactNode => [ + 'As ', + name, + ', the name drawn for this start. The next start draws another.', + ], + bridgeStopped: 'Not running', + bridgeStoppedAbout: 'It starts with FXServer, under a name drawn for that start.', + txadmin: "txAdmin's events", + on: 'On', + off: 'Off', + txadminOn: "Raised right after Observer's own events.", + txadminOff: "Only Observer's own events are raised.", + txadminNext: (on: boolean) => + on ? 'On from the next start of FXServer.' : 'Off from the next start of FXServer.', + txadminFromNext: 'From the next start of FXServer.', + notSet: 'Not set', + notSetAbout: 'The launch settings are not saved yet.', + launchSettings: 'Launch settings', + }, + notes: { + panel: 'In the panel.', + server: 'This server.', + labels: (players: ReactNode): ReactNode => [ + 'Online now shows a label in place of the FiveM name, with a tag. The ', + players, + ' page finds players by it, and each profile keeps it with the resource that gave it.', + ], + startChanges: (changes: ReactNode): ReactNode => [ + changes, + ' lists every resource this event names, and those it left out.', + ], + bans: (bans: ReactNode, message: ReactNode): ReactNode => [ + 'The staff bans from a profile or the ', + bans, + ' page. The ', + message, + ' banned players read is in the settings.', + ], + txadminOn: "txAdmin's events are on: the bridge raises them right after Observer's own.", + txadminOff: "txAdmin's events are off: the bridge raises Observer's events only.", + txadminStopped: (on: boolean) => + on + ? "txAdmin's events are on in the launch settings: the bridge raises them from the next start." + : "txAdmin's events are off in the launch settings: the next start raises Observer's events only.", + refusalMessage: 'refusal message', + }, + }, permissions, permissionAreas: { dashboard: 'Dashboard', diff --git a/web/src/lib/markdown.test.ts b/web/src/lib/markdown.test.ts new file mode 100644 index 00000000..65f8a218 --- /dev/null +++ b/web/src/lib/markdown.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBlocks, parseInline, plainText } from './markdown'; + +const NOTES = [ + 'Observer 0.2.0, built from 0123456789ab.', + '', + '- `observer-0.2.0-windows-x86_64.zip`: Windows 10 or Server 2016 and later, x64.', + '- `SHA256SUMS`: check the archives with `sha256sum -c SHA256SUMS`.', + '', + "## What's Changed", + '* Install updates from the panel by @someone in https://github.com/SensityTech/Observer/pull/45', + '', + '**Full Changelog**: https://github.com/SensityTech/Observer/compare/v0.1.0...v0.2.0', +].join('\r\n'); + +describe('parseBlocks', () => { + it('reads the paragraphs, lists and headings of release notes', () => { + expect(parseBlocks(NOTES).map((block) => block.kind)).toMatchObject([ + 'paragraph', + 'list', + 'heading', + 'list', + 'paragraph', + ]); + }); + + it('keeps a fenced block as it is, with the language it names, and joins the lines of a paragraph', () => { + const blocks = parseBlocks('one\ntwo\n\n```lua\n# not a heading\n```\n1. first\n2. second'); + + expect(blocks).toMatchObject([ + { kind: 'paragraph', text: 'one two' }, + { kind: 'code', text: '# not a heading', lang: 'lua' }, + { kind: 'list', ordered: true, items: [{ text: 'first' }, { text: 'second' }] }, + ]); + }); + + it('reads a table, a bar inside a code span staying in its cell', () => { + const blocks = parseBlocks( + [ + 'Before.', + '| Field | Type | Meaning |', + '|---|:--|---|', + '| `at` | integer | When it happens. |', + '| `kind` | string | `a|b`, or nothing |', + '| `short` |', + 'After.', + ].join('\n'), + ); + + expect(blocks).toMatchObject([ + { kind: 'paragraph', text: 'Before.' }, + { + kind: 'table', + head: [{ text: 'Field' }, { text: 'Type' }, { text: 'Meaning' }], + rows: [ + { cells: [{ text: '`at`' }, { text: 'integer' }, { text: 'When it happens.' }] }, + { cells: [{ text: '`kind`' }, { text: 'string' }, { text: '`a|b`, or nothing' }] }, + { cells: [{ text: '`short`' }, { text: '' }, { text: '' }] }, + ], + }, + { kind: 'paragraph', text: 'After.' }, + ]); + }); + + it('takes lines between bars for text when no rule follows them', () => { + expect(parseBlocks('| not | a table |\nnext')).toMatchObject([ + { kind: 'paragraph', text: '| not | a table | next' }, + ]); + }); +}); + +describe('parseInline', () => { + it('reads code, emphasis and links', () => { + expect(parseInline('**Full** `code` *mild* [docs](https://example.com/a)')).toMatchObject([ + { kind: 'strong', children: [{ kind: 'text', text: 'Full' }] }, + { kind: 'text', text: ' ' }, + { kind: 'code', text: 'code' }, + { kind: 'text', text: ' ' }, + { kind: 'em', children: [{ kind: 'text', text: 'mild' }] }, + { kind: 'text', text: ' ' }, + { + kind: 'link', + href: 'https://example.com/a', + children: [{ kind: 'text', text: 'docs' }], + }, + ]); + }); + + it('never makes a link of an address that is not http or https', () => { + expect(parseInline('[click](javascript:alert(1))')).toMatchObject([{ kind: 'text', text: 'click)' }]); + expect(parseInline('[file](file:///etc/passwd)')).toMatchObject([{ kind: 'text', text: 'file' }]); + expect(parseInline('[elsewhere](//example.com/a)')).toMatchObject([ + { kind: 'text', text: 'elsewhere' }, + ]); + }); + + it('tells a place in the same text and a path beside it from an address', () => { + expect(parseInline('See [Bans](#bans) and [`observer_example`](observer_example).')).toMatchObject([ + { kind: 'text', text: 'See ' }, + { kind: 'local', target: '#bans', children: [{ kind: 'text', text: 'Bans' }] }, + { kind: 'text', text: ' and ' }, + { + kind: 'local', + target: 'observer_example', + children: [{ kind: 'code', text: 'observer_example' }], + }, + { kind: 'text', text: '.' }, + ]); + }); + + it('leaves a snake_case name alone and a bare address without its last stop', () => { + expect(parseInline('observer_bridge and https://example.com/x.')).toMatchObject([ + { kind: 'text', text: 'observer_bridge and ' }, + { + kind: 'link', + href: 'https://example.com/x', + children: [{ kind: 'text', text: 'https://example.com/x' }], + }, + { kind: 'text', text: '.' }, + ]); + }); +}); + +describe('plainText', () => { + it('says a text without its markup', () => { + expect(plainText(parseInline('`observer:shuttingDown` is **raised** [here](#events)'))).toBe( + 'observer:shuttingDown is raised here', + ); + }); +}); diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts new file mode 100644 index 00000000..a76f284a --- /dev/null +++ b/web/src/lib/markdown.ts @@ -0,0 +1,255 @@ +// Markdown as the panel reads it, for release notes and the documentation: headings, lists, +// paragraphs, fenced code, tables, emphasis and links. What it yields is drawn as React elements, +// never as HTML. + +/** Each part carries its place in the text as its key: a text never moves once read. */ +export type Block = { key: string } & ( + | { kind: 'heading'; level: number; text: string } + | { kind: 'list'; ordered: boolean; items: { key: string; text: string }[] } + | { kind: 'code'; text: string; lang: string } + | { kind: 'table'; head: Cell[]; rows: { key: string; cells: Cell[] }[] } + | { kind: 'quote'; text: string } + | { kind: 'rule' } + | { kind: 'paragraph'; text: string } +); + +/** A cell of a table, in Markdown. */ +export type Cell = { key: string; text: string }; + +export type Inline = { key: string } & ( + | { kind: 'text'; text: string } + | { kind: 'code'; text: string } + | { kind: 'strong'; children: Inline[] } + | { kind: 'em'; children: Inline[] } + | { kind: 'link'; href: string; children: Inline[] } + /** A link inside the text's own place: `#section`, or a path beside it. Its reader decides where it leads. */ + | { kind: 'local'; target: string; children: Inline[] } +); + +type Unkeyed = T extends unknown ? Omit : never; + +const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/; +const BULLET = /^\s{0,3}[-*+]\s+(.*)$/; +const NUMBERED = /^\s{0,3}\d{1,9}[.)]\s+(.*)$/; +const FENCE = /^\s{0,3}(```|~~~)\s*([\w+-]*)/; +const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; +const QUOTE = /^\s{0,3}>\s?(.*)$/; +const TABLE_ROW = /^\s{0,3}\|.*\|\s*$/; +const TABLE_RULE = /^\s{0,3}\|(\s*:?-+:?\s*\|)+\s*$/; + +/** The cells of a table's line. A `|` inside a code span belongs to its cell. */ +const cellsOf = (line: string): string[] => { + const cells: string[] = []; + let cell = ''; + let code = false; + for (const char of line.trim().replace(/^\|/, '').replace(/\|$/, '')) { + if (char === '`') { + code = !code; + } + if (char === '|' && !code) { + cells.push(cell.trim()); + cell = ''; + } else { + cell += char; + } + } + cells.push(cell.trim()); + return cells; +}; + +export function parseBlocks(source: string): Block[] { + const lines = source.replace(/\r\n?/g, '\n').split('\n'); + const blocks: Block[] = []; + const add = (block: Unkeyed) => blocks.push({ ...block, key: `b${blocks.length}` } as Block); + let paragraph: string[] = []; + const flush = () => { + if (paragraph.length > 0) { + add({ kind: 'paragraph', text: paragraph.join(' ') }); + paragraph = []; + } + }; + for (let at = 0; at < lines.length; at += 1) { + const line = lines[at] ?? ''; + if (line.trim() === '') { + flush(); + continue; + } + const fence = FENCE.exec(line); + if (fence !== null) { + flush(); + const marker = fence[1] ?? '```'; + const code: string[] = []; + at += 1; + while (at < lines.length && !(lines[at] ?? '').trimStart().startsWith(marker)) { + code.push(lines[at] ?? ''); + at += 1; + } + add({ kind: 'code', text: code.join('\n'), lang: fence[2] ?? '' }); + continue; + } + const heading = HEADING.exec(line); + if (heading !== null) { + flush(); + add({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' }); + continue; + } + if (TABLE_ROW.test(line) && TABLE_RULE.test(lines[at + 1] ?? '')) { + flush(); + const key = `b${blocks.length}`; + const head = cellsOf(line).map((text, index) => ({ key: `${key}.h${index}`, text })); + const rows: { key: string; cells: Cell[] }[] = []; + at += 2; + while (at < lines.length && TABLE_ROW.test(lines[at] ?? '')) { + const cells = cellsOf(lines[at] ?? ''); + const row = `${key}.${rows.length}`; + // As many cells as the head has, whatever the line holds. + rows.push({ + key: row, + cells: head.map((_, index) => ({ key: `${row}.${index}`, text: cells[index] ?? '' })), + }); + at += 1; + } + at -= 1; + add({ kind: 'table', head, rows }); + continue; + } + if (RULE.test(line)) { + flush(); + add({ kind: 'rule' }); + continue; + } + const item = BULLET.exec(line) ?? NUMBERED.exec(line); + if (item !== null) { + flush(); + const ordered = NUMBERED.test(line) && !BULLET.test(line); + const text = item[1] ?? ''; + const last = blocks.at(-1); + if (last?.kind === 'list' && last.ordered === ordered) { + last.items.push({ key: `${last.key}.${last.items.length}`, text }); + } else { + add({ kind: 'list', ordered, items: [{ key: `b${blocks.length}.0`, text }] }); + } + continue; + } + const quote = QUOTE.exec(line); + if (quote !== null) { + flush(); + const text = quote[1] ?? ''; + const last = blocks.at(-1); + if (last?.kind === 'quote') { + last.text = `${last.text} ${text}`; + } else { + add({ kind: 'quote', text }); + } + continue; + } + // A line that carries on the list item above it. + const last = blocks.at(-1); + if (paragraph.length === 0 && last?.kind === 'list' && /^\s{2,}\S/.test(line)) { + const carried = last.items.at(-1); + if (carried !== undefined) { + carried.text = `${carried.text} ${line.trim()}`; + } + continue; + } + paragraph.push(line.trim()); + } + flush(); + return blocks; +} + +const CODE = /^`([^`]+)`/; +const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)/; +const ANGLED = /^<(https?:\/\/[^>\s]+)>/; +const BARE = /^https?:\/\/[^\s<>]+/; +const STRONG = /^(\*\*|__)(?=\S)(.+?)(?<=\S)\1/; +const EM = /^\*(?=[^\s*])(.+?)(?<=[^\s*])\*/; +// A place in the same text, or a path beside it: no scheme, so never an address of its own. +const LOCAL = /^(?:#[\w-]+|[\w-][\w./-]*(?:#[\w-]+)?)$/; + +export function parseInline(text: string): Inline[] { + const nodes: Inline[] = []; + const keyed = (node: Unkeyed) => nodes.push({ ...node, key: `i${nodes.length}` } as Inline); + let plain = ''; + const push = (node: Unkeyed) => { + if (plain !== '') { + keyed({ kind: 'text', text: plain }); + plain = ''; + } + keyed(node); + }; + let at = 0; + while (at < text.length) { + const rest = text.slice(at); + const char = text.charAt(at); + let match: RegExpExecArray | null = null; + if (char === '`' && (match = CODE.exec(rest)) !== null) { + push({ kind: 'code', text: match[1] ?? '' }); + } else if (char === '[' && (match = LINK.exec(rest)) !== null) { + const target = match[2] ?? ''; + const href = safeHref(target); + if (href !== null) { + push({ kind: 'link', href, children: parseInline(match[1] ?? '') }); + } else if (LOCAL.test(target)) { + push({ kind: 'local', target, children: parseInline(match[1] ?? '') }); + } else { + plain += match[1] ?? ''; + } + } else if (char === '<' && (match = ANGLED.exec(rest)) !== null) { + push(urlLink(match[1] ?? '')); + } else if ( + char === 'h' && + /\w/.test(text[at - 1] ?? ' ') === false && + (match = BARE.exec(rest)) !== null + ) { + // A sentence's last stop or a closing bracket is not part of the address. + const url = match[0].replace(/[.,;:!?'")\]]+$/, ''); + push(urlLink(url)); + at += url.length; + continue; + } else if ((char === '*' || char === '_') && (match = STRONG.exec(rest)) !== null) { + push({ kind: 'strong', children: parseInline(match[2] ?? '') }); + } else if (char === '*' && (match = EM.exec(rest)) !== null) { + push({ kind: 'em', children: parseInline(match[1] ?? '') }); + } + if (match === null) { + plain += char; + at += 1; + } else { + at += match[0].length; + } + } + if (plain !== '') { + keyed({ kind: 'text', text: plain }); + } + return nodes; +} + +/** What the nodes say, without their markup. */ +export const plainText = (nodes: Inline[]): string => + nodes.map((node) => ('text' in node ? node.text : plainText(node.children))).join(''); + +/** An http or https address, or null for anything else (`javascript:`, a relative path…). */ +const safeHref = (href: string): string | null => { + try { + const url = new URL(href); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; + } catch { + return null; + } +}; + +const GITHUB_ITEM = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)$/; +const GITHUB_COMPARE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/compare\/(\S+)$/; + +/** A pull request, an issue or a comparison on GitHub reads as GitHub shows it, `#45`. */ +const urlLink = (url: string): Unkeyed => { + const href = safeHref(url); + if (href === null) { + return { kind: 'text', text: url }; + } + const item = GITHUB_ITEM.exec(url); + const compare = GITHUB_COMPARE.exec(url); + const text = item !== null ? `#${item[1] ?? ''}` : compare !== null ? (compare[1] ?? url) : url; + return { kind: 'link', href, children: [{ key: 'i0', kind: 'text', text }] }; +}; diff --git a/web/src/lib/useCopy.ts b/web/src/lib/useCopy.ts new file mode 100644 index 00000000..cfcff625 --- /dev/null +++ b/web/src/lib/useCopy.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +/** How long "Copied" stays before the button offers to copy again. */ +const COPIED_FOR = 2_000; + +export type CopyState = 'idle' | 'copied' | 'blocked'; + +/** + * Copies a text in one click where the browser allows it. `copied` goes back to `idle` after a + * moment, unless it is `kept`; `blocked` stays, for whoever shows the text to offer it another way. + */ +export const useCopy = ({ kept = false }: { kept?: boolean } = {}) => { + const [state, setState] = useState('idle'); + useEffect(() => { + if (state !== 'copied' || kept) { + return; + } + const timer = setTimeout(() => setState('idle'), COPIED_FOR); + return () => clearTimeout(timer); + }, [state, kept]); + /** Whether the text was copied. */ + const copy = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setState('copied'); + return true; + } catch { + // No clipboard outside a secure context (plain HTTP on a LAN address). + setState('blocked'); + return false; + } + }; + return { state, copy }; +}; diff --git a/web/src/pages/DocsPage.test.tsx b/web/src/pages/DocsPage.test.tsx new file mode 100644 index 00000000..a04f59ee --- /dev/null +++ b/web/src/pages/DocsPage.test.tsx @@ -0,0 +1,361 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { act, cleanup, fireEvent, render, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '../api/client'; +import { integrationDocsQuery } from '../api/docs'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import type { Me } from '../api/generated/Me'; +import type { ServerStatus } from '../api/generated/ServerStatus'; +import { MeContext } from '../auth/me'; +import { integrationDocs } from '../docs/fixtures'; +import { buildDocs, fieldsOf, handlerOf } from '../docs/model'; +import { NOTED } from '../docs/Notes'; +import { en } from '../i18n/en'; +import { I18nProvider } from '../i18n/I18nProvider'; +import { PaletteContext } from '../shell/palette'; +import { DocsPage } from './DocsPage'; + +/** The status the shell would follow, set by each test. */ +const server = vi.hoisted(() => { + const listeners = new Set<() => void>(); + const store = { + status: null as ServerStatus | null, + set: (next: ServerStatus | null) => { + store.status = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + return store; +}); +vi.mock('../server/context', async () => { + const { useSyncExternalStore } = await import('react'); + return { + useServerStatus: () => ({ + status: useSyncExternalStore(server.subscribe, () => server.status), + refused: false, + }), + }; +}); + +/** A status with what the page reads of it. */ +const status = (generation: number, state: ServerStatus['state'], startedAt: string | null = null) => + ({ generation, state, startedAt }) as ServerStatus; + +const OWNER: Me = { + id: 1, + username: 'ada', + isOwner: true, + permissions: 'fffffff', + mustChangePassword: false, +}; +/** Sees the dashboard and nothing else: is told what the server does, and opens none of the pages named. */ +const VIEWER: Me = { id: 2, username: 'grace', isOwner: false, permissions: '1', mustChangePassword: false }; +/** Holds no permission: reads the contract, and is not told what the server does. */ +const READER: Me = { id: 3, username: 'alan', isOwner: false, permissions: '0', mustChangePassword: false }; + +const words = en.docs; +const BRIDGE = 'sys_k3jx7qmw2pza'; +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); +const openPalette = vi.fn(); +const writeText = vi.fn(async (_text: string) => {}); + +const open = async ( + docs: IntegrationDocs | ApiError, + { me = OWNER, path = '/docs' }: { me?: Me; path?: string } = {}, +) => { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: false, retryOnMount: false } }, + }); + if (docs instanceof ApiError) { + await client.prefetchQuery({ ...integrationDocsQuery, queryFn: () => Promise.reject(docs) }); + } else { + client.setQueryData(integrationDocsQuery.queryKey, docs); + } + const root = createRootRoute({ component: Outlet }); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/docs', component: DocsPage }), + ]), + history: createMemoryHistory({ initialEntries: [path] }), + }); + const view = render( + + + + + + + + + , + ); + await settle(); + await settle(); + return { ...view, client, router }; +}; + +/** The card a section or an event has on the page. */ +const card = (id: string) => document.getElementById(id) as HTMLElement; +const facts = () => within(card('this-server')); + +describe('DocsPage', () => { + beforeEach(() => { + // jsdom lays nothing out, so it has no scrolling to the section a link leads to. + Element.prototype.scrollIntoView = vi.fn(); + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json(integrationDocs())), + ); + server.set(null); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('should draw every section of the contract in its order, under contents that list them', async () => { + const docs = integrationDocs(); + const model = buildDocs(docs); + const { getAllByRole, getByRole } = await open(docs); + + expect(getByRole('heading', { level: 1 }).textContent).toBe(`${words.title} ${words.gloss}`); + expect(getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual([ + words.server.title, + ...model.sections.map((section) => section.title), + ]); + const contents = within(getByRole('navigation', { name: words.toc })); + const listed = contents + .getAllByRole('link') + .map((link) => [link.textContent, link.getAttribute('href')]); + expect(listed[0]).toEqual([words.server.title, '#this-server']); + for (const section of model.sections) { + expect(listed).toContainEqual([section.title, `#${section.id}`]); + for (const { name } of section.exports) { + expect(listed).toContainEqual([name, `#${section.id}`]); + } + } + expect(listed).toContainEqual(['shuttingDown', '#observershuttingdown']); + // Each export shows where it is documented, with the parameters its script declares, on the + // line that heads the section: the contract's own text may name it too. + for (const section of model.sections.filter((documented) => documented.exports.length > 0)) { + const heading = within(within(card(section.id)).getByText(words.exports)); + for (const { name, parameters } of section.exports) { + expect(heading.getByText(`${name}(${parameters.join(', ')})`)).toBeTruthy(); + } + } + expect(listed).toHaveLength( + 1 + + model.sections.length + + model.sections.flatMap((section) => [...section.entries, ...section.exports]).length, + ); + }); + + it('should give each event the bridge raises its card: when it comes, its fields and a handler to copy', async () => { + const docs = integrationDocs(); + const model = buildDocs(docs); + const script = docs.example.map((file) => file.text).join('\n'); + await open(docs); + + expect(docs.events).not.toHaveLength(0); + for (const event of docs.events) { + const entry = model.sections + .flatMap((section) => section.entries) + .find((found) => found.title === event); + const own = within(card(entry?.id ?? '')); + + expect(own.getByRole('heading', { level: 3 }).textContent, event).toBe(event); + expect(own.getByText(words.event), event).toBeTruthy(); + for (const field of entry === undefined ? [] : fieldsOf(entry)) { + expect(own.getAllByText(field).length, `${event} ${field}`).toBeGreaterThan(0); + } + const handler = entry === undefined ? '' : handlerOf(entry, script); + expect(card(entry?.id ?? '').querySelector('pre')?.textContent, event).toBe(handler); + fireEvent.click(own.getByRole('button', { name: words.copyNamed(words.handlerOf(event)) })); + expect(writeText, event).toHaveBeenLastCalledWith(handler); + } + await settle(); + }); + + it('should say what a server that does not run will do, and what one that runs does', async () => { + const stopped = await open(integrationDocs(), { me: VIEWER }); + expect(facts().getByText(words.server.version(integrationDocs().contractVersion))).toBeTruthy(); + expect(facts().getByText(words.server.stopped)).toBeTruthy(); + expect(facts().getByText(words.server.bridgeStopped)).toBeTruthy(); + expect(facts().getByText(words.server.notSet)).toBeTruthy(); + expect(stopped.queryByRole('link', { name: words.server.launchSettings })).toBeNull(); + cleanup(); + + await open(integrationDocs({ txadminEventsNext: true })); + expect(facts().getByText(words.server.on)).toBeTruthy(); + expect(facts().getByText(words.server.txadminFromNext, { exact: false })).toBeTruthy(); + expect(facts().getByRole('link', { name: words.server.launchSettings }).getAttribute('href')).toBe( + '/settings/launch#setting-txadminEvents', + ); + cleanup(); + + await open( + integrationDocs({ bridge: BRIDGE, starting: true, txadminEvents: true, txadminEventsNext: true }), + ); + expect(facts().getByText(words.server.running)).toBeTruthy(); + expect(facts().getByText(words.server.bridgeStarting)).toBeTruthy(); + expect(facts().getByText(BRIDGE)).toBeTruthy(); + cleanup(); + + server.set(status(3, 'online', '2026-11-11T13:02:00Z')); + await open(integrationDocs({ bridge: BRIDGE, txadminEvents: false, txadminEventsNext: true })); + expect(facts().getByText(words.server.bridgeRunning)).toBeTruthy(); + expect(facts().getByText(words.server.startedAt('').trim(), { exact: false })).toBeTruthy(); + expect(facts().getByText(words.server.off)).toBeTruthy(); + // The setting was saved since this start. + expect( + facts().getByText(`${words.server.txadminOff} ${words.server.txadminNext(true)}`, { + exact: false, + }), + ).toBeTruthy(); + }); + + it('should add a word of the panel under the sections it has one for, each held to the contract', async () => { + const docs = integrationDocs({ bridge: BRIDGE, txadminEvents: false, txadminEventsNext: false }); + const model = buildDocs(docs); + const ids = model.sections.flatMap((section) => [ + section.id, + ...section.entries.map((entry) => entry.id), + ]); + for (const noted of NOTED) { + expect(ids, `CONTRACT.md has no heading at #${noted} any more`).toContain(noted); + } + + await open(docs); + const labels = within(card('player-labels')); + expect(labels.getByText(words.notes.panel)).toBeTruthy(); + expect(labels.getByRole('link', { name: en.nav.players }).getAttribute('href')).toBe('/players'); + expect( + within(card('observerstartchanges')) + .getByRole('link', { name: en.nav.changes }) + .getAttribute('href'), + ).toBe('/changes'); + const bans = within(card('bans')); + expect(bans.getByRole('link', { name: en.nav.bans }).getAttribute('href')).toBe('/bans'); + expect(bans.getByRole('link', { name: words.notes.refusalMessage }).getAttribute('href')).toBe( + '/settings/bans#setting-refusalMessage', + ); + const txadmin = within(card('txadmins-events')); + expect(txadmin.getByText(words.notes.server)).toBeTruthy(); + expect(txadmin.getByText(words.notes.txadminOff, { exact: false })).toBeTruthy(); + cleanup(); + + // An account that opens none of those pages reads their names. + await open(docs, { me: VIEWER }); + for (const noted of NOTED) { + const footer = card(noted).querySelector('footer') as HTMLElement; + expect(footer, noted).not.toBeNull(); + expect(within(footer).queryByRole('link'), noted).toBeNull(); + } + expect(card('player-labels').querySelector('footer')?.textContent).toContain(en.nav.players); + }); + + it('should leave what the server does out for an account that is not told it', async () => { + const docs = integrationDocs(null); + const { getAllByRole, getByRole } = await open(docs, { me: READER }); + + expect(card('this-server')).toBeNull(); + expect(card('txadmins-events').querySelector('footer')).toBeNull(); + expect(card('bans').querySelector('footer')).not.toBeNull(); + const contents = within(getByRole('navigation', { name: words.toc })); + expect(contents.queryByRole('link', { name: words.server.title })).toBeNull(); + expect(getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual( + buildDocs(docs).sections.map((section) => section.title), + ); + }); + + it('should lead the links of the contract to their place on the page, and show the files it links to', async () => { + const { getByRole } = await open(integrationDocs()); + + const events = within(card('events')); + expect(events.getAllByRole('link', { name: 'Bans' })[0]?.getAttribute('href')).toBe('#bans'); + expect(events.getByRole('link', { name: 'observer:playerRefused' }).getAttribute('href')).toBe( + '#observerplayerrefused', + ); + // Its own name, in its own card, is not a link to itself. + expect( + within(card('observerplayerrefused')).queryByRole('link', { name: 'observer:playerRefused' }), + ).toBeNull(); + const example = within(card('example')); + expect(example.getByRole('link', { name: 'observer_example' }).getAttribute('href')).toBe( + '#files-observer_example', + ); + const file = getByRole('button', { name: /^observer_example\/server\.lua/ }); + const code = document.getElementById(file.getAttribute('aria-controls') ?? '') as HTMLElement; + expect(file.getAttribute('aria-expanded')).toBe('false'); + expect(code.hidden).toBe(true); + fireEvent.click(file); + expect(file.getAttribute('aria-expanded')).toBe('true'); + expect(code.hidden).toBe(false); + expect(code.textContent).toContain("AddEventHandler('observer:shuttingDown'"); + }); + + it('should open on the section its address names, and copy the address of a section', async () => { + const { getByRole } = await open(integrationDocs(), { path: '/docs#bans' }); + + expect(vi.mocked(Element.prototype.scrollIntoView).mock.contexts).toContain(card('bans')); + fireEvent.click(getByRole('button', { name: words.copyLink('Bans') })); + await settle(); + expect(writeText).toHaveBeenLastCalledWith(`${location.origin}/docs#bans`); + expect(within(card('bans')).getByText(words.linkCopied)).toBeTruthy(); + }); + + it('should give the link to read when the browser refuses to copy it', async () => { + writeText.mockRejectedValueOnce(new Error('not allowed')); + const { getByRole } = await open(integrationDocs()); + + fireEvent.click(getByRole('button', { name: words.copyLink('Security') })); + await settle(); + + expect( + within(card('security')).getByText( + en.common.copyBlockedValue(`${location.origin}/docs#security`), + ), + ).toBeTruthy(); + }); + + it('should open the search from its button, and say when the documentation cannot be read', async () => { + const read = await open(integrationDocs()); + fireEvent.click(read.getByRole('button', { name: new RegExp(words.find) })); + expect(openPalette).toHaveBeenCalledOnce(); + cleanup(); + + const failed = await open(new ApiError(500, 'internal', {})); + expect(failed.getByRole('alert').textContent).toContain(words.failed); + expect(failed.queryByRole('navigation', { name: words.toc })).toBeNull(); + }); + + it('should read what the server does again when it starts or stops, and not before', async () => { + server.set(status(3, 'stopped')); + const { client } = await open(integrationDocs()); + const invalidate = vi.spyOn(client, 'invalidateQueries'); + expect(invalidate).not.toHaveBeenCalled(); + + act(() => server.set(status(4, 'starting'))); + await settle(); + + expect(invalidate).toHaveBeenCalledExactlyOnceWith({ queryKey: integrationDocsQuery.queryKey }); + }); +}); diff --git a/web/src/pages/DocsPage.tsx b/web/src/pages/DocsPage.tsx new file mode 100644 index 00000000..19b3b5ba --- /dev/null +++ b/web/src/pages/DocsPage.tsx @@ -0,0 +1,168 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useLocation } from '@tanstack/react-router'; +import { Search } from 'lucide-react'; +import { Fragment, useEffect, useMemo, useRef } from 'react'; + +import { integrationDocsQuery } from '../api/docs'; +import type { IntegrationDocs } from '../api/generated/IntegrationDocs'; +import { buttonVariants } from '../components/Button'; +import { Notice } from '../components/Notice'; +import { EntryCard, SectionCard, folderOf } from '../docs/Cards'; +import { buildDocs } from '../docs/model'; +import { Prose, ReadingContext } from '../docs/Prose'; +import { THIS_SERVER, ThisServer } from '../docs/ThisServer'; +import { Toc, type TocGroup } from '../docs/Toc'; +import { errorMessage } from '../i18n/errors'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { useServerStatus } from '../server/context'; +import { useOpenPalette } from '../shell/palette'; + +/** + * What Observer gives the server's resources, for whoever writes them: the integration contract as + * this build ships it, drawn section by section, with what this server does of it and where the + * panel shows its effect. The text is the binary's own: this page writes none of it. + */ +export function DocsPage() { + const { t } = useI18n(); + const docs = useQuery(integrationDocsQuery); + const openPalette = useOpenPalette(); + useServerSide(); + + return ( +
    +
    +

    + {t.docs.title} {t.docs.gloss} +

    + +
    + {/* What was read stays on the page when reading it again fails. */} + {docs.data !== undefined ? ( + + ) : docs.isError ? ( + + {t.docs.failed} {errorMessage(t, docs.error)} + + ) : ( +
    + {t.docs.loading} +
    +
    +
    +
    + )} +
    + ); +} + +/** The server's side of the documentation changes with each start and stop: it is read again then. */ +const useServerSide = () => { + const client = useQueryClient(); + const { status } = useServerStatus(); + const run = status === null ? null : `${status.generation} ${status.state}`; + const last = useRef(run); + useEffect(() => { + if (run === null) { + return; + } + if (last.current !== null && last.current !== run) { + void client.invalidateQueries({ queryKey: integrationDocsQuery.queryKey }); + } + last.current = run; + }, [client, run]); +}; + +/** `observer:shuttingDown` is `shuttingDown` under its section, where the prefix says nothing more. */ +const short = (event: string) => event.slice(event.indexOf(':') + 1); + +function Documentation({ docs }: { docs: IntegrationDocs }) { + const { t } = useI18n(); + const { contract, events, exports, example, server } = docs; + const situated = server !== null; + const model = useMemo( + () => buildDocs({ contract, events, exports, example }), + [contract, events, exports, example], + ); + const groups = useMemo( + () => [ + ...(situated + ? [ + { + label: t.docs.toc, + items: [ + { key: THIS_SERVER, id: THIS_SERVER, label: t.docs.server.title, sub: false }, + ], + }, + ] + : []), + { + label: t.docs.contract, + items: model.sections.flatMap((section) => [ + { key: section.id, id: section.id, label: section.title, sub: false }, + ...section.exports.map(({ name }) => ({ + key: `${section.id} ${name}`, + id: section.id, + label: name, + sub: true, + })), + ...section.entries.map((entry) => ({ + key: entry.id, + id: entry.id, + label: entry.event ? short(entry.title) : entry.title, + sub: true, + })), + ]), + }, + ], + [model, t, situated], + ); + const reading = useMemo( + () => ({ anchors: model.anchors, paths: new Set(example.map(folderOf)), own: '' }), + [model.anchors, example], + ); + + // A link to a section opens on it, once the page has drawn what it leads to. + const hash = useLocation({ select: (location) => location.hash }); + useEffect(() => { + if (hash !== '') { + document.getElementById(hash)?.scrollIntoView(); + } + }, [hash]); + + return ( + <> +
    + + + +
    +
    + +
    + {server !== null && } + {model.sections.map((section) => ( + + + {section.entries.map((entry) => ( + + ))} + + ))} +
    +
    + + ); +} diff --git a/web/src/router.test.tsx b/web/src/router.test.tsx index 55da8898..5201c16e 100644 --- a/web/src/router.test.tsx +++ b/web/src/router.test.tsx @@ -46,6 +46,7 @@ vi.mock('./shell/FocusShell', async () => { }); vi.mock('./pages/OverviewPage', () => ({ OverviewPage: () =>

    Overview

    })); vi.mock('./pages/ServerSetupPage', () => ({ ServerSetupPage: () =>

    Server setup

    })); +vi.mock('./pages/DocsPage', () => ({ DocsPage: () =>

    Documentation

    })); vi.mock('./pages/SettingsPage', async () => { const { Outlet } = await import('@tanstack/react-router'); return { SettingsPage: () => }; @@ -167,6 +168,14 @@ describe('the panel router', () => { expect(router.state.location.pathname).toBe(`/settings/${category}`); }); + it('should open the documentation for an account that holds no permission', async () => { + const { findByRole, router } = panel('/docs#bans', [[meQuery.queryKey, NOBODY]]); + + await findByRole('heading', { name: 'Documentation' }); + expect(router.state.location.pathname).toBe('/docs'); + expect(router.state.location.hash).toBe('bans'); + }); + it('should say a category of the settings that does not exist is not found', async () => { const { findByText, queryByRole } = panel('/settings/everything', [[meQuery.queryKey, OWNER]]); diff --git a/web/src/router.tsx b/web/src/router.tsx index 03eb6890..62a1cf9a 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -66,6 +66,7 @@ const BackupsPage = lazyCode(() => ); const AuditPage = lazyCode(() => import('./pages/AuditPage').then((page) => ({ default: page.AuditPage }))); const AboutPage = lazyCode(() => import('./pages/AboutPage').then((page) => ({ default: page.AboutPage }))); +const DocsPage = lazyCode(() => import('./pages/DocsPage').then((page) => ({ default: page.DocsPage }))); const DiagnosticsPage = lazyCode(() => import('./pages/DiagnosticsPage').then((page) => ({ default: page.DiagnosticsPage })), ); @@ -509,6 +510,14 @@ const aboutRoute = createRoute({ component: AboutPage, }); +// The contract Observer ships holds no secret: any signed-in account reads it, as the About page. +// What the server does of it comes only to those who see the dashboard: the API leaves it out. +const docsRoute = createRoute({ + getParentRoute: () => panelRoute, + path: '/docs', + component: DocsPage, +}); + const routeTree = rootRoute.addChildren([ setupRoute, loginRoute, @@ -544,6 +553,7 @@ const routeTree = rootRoute.addChildren([ settingsRoute.addChildren([settingsIndexRoute, settingsCategoryRoute]), accountRoute, aboutRoute, + docsRoute, ]), focusRoute.addChildren([serverSetupRoute]), ]); diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 804f755e..989ed47b 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -183,7 +183,7 @@ describe('AppShell', () => { expect(without.queryByRole('button', { name: en.nav.groupDebug })).toBeNull(); }); - it('should keep the settings at the foot of the rail, for every account', async () => { + it('should keep the documentation and the settings at the foot of the rail, for every account', async () => { const { findByRole, getByRole } = shell({ ...OWNER, id: 2, @@ -196,6 +196,14 @@ describe('AppShell', () => { const rail = within(getByRole('complementary', { name: en.nav.label })); const settings = rail.getByRole('link', { name: en.nav.settings }); expect(settings.getAttribute('href')).toBe('/settings'); + const docs = rail.getByRole('link', { name: en.nav.docs }); + expect(docs.getAttribute('href')).toBe('/docs'); + expect(docs.compareDocumentPosition(settings) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); + expect( + within(getByRole('navigation', { name: en.nav.sections })).queryByRole('link', { + name: en.nav.docs, + }), + ).toBeNull(); expect( within(getByRole('navigation', { name: en.nav.sections })).queryByRole('link', { name: en.nav.settings, diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx index b2581e16..0f308a63 100644 --- a/web/src/shell/AppShell.tsx +++ b/web/src/shell/AppShell.tsx @@ -23,6 +23,7 @@ import { StatusBar } from './StatusBar'; import { UpdateSignal } from '../updates/UpdateSignal'; import { CommandPalette } from './CommandPalette'; import { navSections, opensEntry, type NavEntry } from './nav'; +import { PaletteContext } from './palette'; import { UserMenu } from './UserMenu'; export function AppShell() { @@ -175,7 +176,9 @@ export function AppShell() {
    }> - + + +
    diff --git a/web/src/shell/CommandPalette.test.tsx b/web/src/shell/CommandPalette.test.tsx index c174d9f9..b8160301 100644 --- a/web/src/shell/CommandPalette.test.tsx +++ b/web/src/shell/CommandPalette.test.tsx @@ -3,9 +3,11 @@ import { cleanup, fireEvent, render } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { integrationDocsQuery } from '../api/docs'; import type { Me } from '../api/generated/Me'; import type { OnlinePlayers } from '../api/generated/OnlinePlayers'; import { onlineQuery } from '../api/players'; +import { integrationDocs } from '../docs/fixtures'; import { en } from '../i18n/en'; import { I18nProvider } from '../i18n/I18nProvider'; import { CommandPalette } from './CommandPalette'; @@ -38,6 +40,7 @@ const listed = () => const palette = (me: Me = OWNER) => { const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); client.setQueryData(onlineQuery.queryKey, ONLINE); + client.setQueryData(integrationDocsQuery.queryKey, integrationDocs()); const view = render( @@ -80,6 +83,7 @@ describe('CommandPalette', () => { en.nav.resources, en.nav.schedule, en.nav.statistics, + en.nav.docs, en.nav.settings, ]); }); @@ -111,6 +115,34 @@ describe('CommandPalette', () => { }); }); + it('should find an event of the documentation by any word of its name, and open the page on it', () => { + const { field, getByText } = palette(VIEWER); + + fireEvent.change(field(), { target: { value: 'rescheduled' } }); + + expect(listed()).toEqual(['observer:restartRescheduled']); + expect(getByText(en.palette.doc(en.palette.docKinds.event))).not.toBeNull(); + fireEvent.keyDown(field(), { key: 'Enter' }); + expect(navigate).toHaveBeenCalledWith({ to: '/docs', hash: 'observerrestartrescheduled' }); + }); + + it('should find a section, an export and an event txAdmin names, each where the page documents it', () => { + const { field, getByText } = palette(VIEWER); + + fireEvent.change(field(), { target: { value: 'label' } }); + expect(listed()).toEqual(['Player labels', 'setPlayerLabel', 'clearPlayerLabel']); + fireEvent.click(getByText('clearPlayerLabel')); + expect(navigate).toHaveBeenLastCalledWith({ to: '/docs', hash: 'player-labels' }); + cleanup(); + + const again = palette(VIEWER); + fireEvent.change(again.field(), { target: { value: 'serverShuttingDown' } }); + expect(listed()).toEqual(['txAdmin:events:serverShuttingDown']); + expect(again.getByText(en.palette.doc(en.palette.docKinds.txadmin))).not.toBeNull(); + fireEvent.keyDown(again.field(), { key: 'Enter' }); + expect(navigate).toHaveBeenLastCalledWith({ to: '/docs', hash: 'txadmins-events' }); + }); + it('should find a connected player whose profile is known, by name or by ID', () => { const { field, getByText } = palette(); diff --git a/web/src/shell/CommandPalette.tsx b/web/src/shell/CommandPalette.tsx index 23a2aa6d..1403423c 100644 --- a/web/src/shell/CommandPalette.tsx +++ b/web/src/shell/CommandPalette.tsx @@ -1,13 +1,16 @@ import { useQuery } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; -import { Contact, Search, SlidersHorizontal, Wrench, type LucideIcon } from 'lucide-react'; -import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { BookOpen, Contact, Search, SlidersHorizontal, Wrench, type LucideIcon } from 'lucide-react'; +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { integrationDocsQuery } from '../api/docs'; import type { Me } from '../api/generated/Me'; import { Permission } from '../api/generated/Permission'; import { onlineQuery } from '../api/players'; import { allows } from '../auth/me'; +import { buildDocs, docIndex } from '../docs/model'; import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; import { anchorOf, search as searchSettings } from '../settings/catalog'; import { navSections, opensEntry } from './nav'; @@ -20,6 +23,8 @@ type Result = { label: string; /** What kind of place it is, or where it lies. */ hint: string; + /** A name of the code, an event's or an export's, shown as code is. */ + code?: boolean; go: () => void; }; @@ -34,8 +39,9 @@ const asksForPalette = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k'; /** - * Goes anywhere in the panel from the keyboard: a page, a setting by its name, a player who is - * connected. Ctrl K opens it from any page; it lists only what the account may open. + * Goes anywhere in the panel from the keyboard: a page, a setting by its name, a section, an event + * or an export of the documentation, a player who is connected. Ctrl K opens it from any page; it + * lists only what the account may open. */ export function CommandPalette({ me, @@ -76,6 +82,12 @@ function Palette({ me, onClose }: { me: Me; onClose: () => void }) { const [active, setActive] = useState(0); const seesPlayers = allows(me, Permission.PlayersView); const online = useQuery({ ...onlineQuery, enabled: seesPlayers, refetchInterval: false }); + // The contract only changes with the build: its sections are read once. + const contract = useQuery({ ...integrationDocsQuery, staleTime: Infinity }).data; + const documented = useMemo( + () => (contract === undefined ? [] : docIndex(buildDocs(contract))), + [contract], + ); useLayoutEffect(() => { const element = dialog.current; @@ -149,6 +161,18 @@ function Palette({ me, onClose }: { me: Me; onClose: () => void }) { ? pages : [ ...pages.filter((page) => matches(page.label)), + // `restartScheduled` is also found by `scheduled`: a name of the code is read word by word. + ...documented + .filter(({ label }) => matches(`${label} ${label.replace(/([a-z])([A-Z])/g, '$1 $2')}`)) + .slice(0, MAX_OF_KIND) + .map((match) => ({ + key: `doc ${match.kind} ${match.label}`, + icon: BookOpen, + label: match.label, + hint: words.doc(words.docKinds[match.kind]), + code: match.kind !== 'section', + go: leave(() => void navigate({ to: '/docs', hash: match.id })), + })), // Those named by what was asked first, then those whose explanation says it. ...searchSettings(t, me, query) .map((match) => { @@ -263,7 +287,13 @@ function Palette({ me, onClose }: { me: Me; onClose: () => void }) { className="flex-none text-ink-3" aria-hidden="true" /> - + {result.label} {result.hint} diff --git a/web/src/shell/nav.ts b/web/src/shell/nav.ts index 092c6fdd..2f8491ff 100644 --- a/web/src/shell/nav.ts +++ b/web/src/shell/nav.ts @@ -1,6 +1,7 @@ import { Ban, Blocks, + BookOpen, CalendarClock, ChartLine, Contact, @@ -44,6 +45,7 @@ export type NavEntry = { | '/bans' | '/changes' | '/drops' + | '/docs' | '/settings' | '/tools' | '/cfg' @@ -75,7 +77,7 @@ export const opensEntry = (me: Me, { access }: Pick) => /** * The panel's pages as the rail lists them, by what one comes for: a person, the server's setup, - * what the server did, the upkeep. The settings sit at the rail's foot. + * what the server did, the upkeep. The documentation and the settings sit at the rail's foot. */ export const navSections = (t: Messages) => ({ main: [ @@ -137,8 +139,10 @@ export const navSections = (t: Messages) => ({ { to: '/tools', label: t.nav.tools, icon: Wrench, access: 'owner', nested: true }, { to: '/audit', label: t.nav.audit, icon: ScrollText, access: Permission.AuditView }, ] satisfies NavEntry[], - // Every account opens its own settings, and the categories its permissions open. + // Every account reads the documentation, which holds no secret, and opens its own settings and + // the categories its permissions open. settings: [ + { to: '/docs', label: t.nav.docs, icon: BookOpen, access: 'everyone' }, { to: '/settings', label: t.nav.settings, icon: Settings, access: 'everyone', nested: true }, ] satisfies NavEntry[], }); diff --git a/web/src/shell/palette.ts b/web/src/shell/palette.ts new file mode 100644 index 00000000..dcb217ca --- /dev/null +++ b/web/src/shell/palette.ts @@ -0,0 +1,6 @@ +import { createContext, use } from 'react'; + +/** Opens the panel's search, for a page that offers it besides the rail's button and Ctrl K. */ +export const PaletteContext = createContext<() => void>(() => {}); + +export const useOpenPalette = () => use(PaletteContext); diff --git a/web/src/updates/Markdown.test.tsx b/web/src/updates/Markdown.test.tsx index db9efd6f..ece4ac47 100644 --- a/web/src/updates/Markdown.test.tsx +++ b/web/src/updates/Markdown.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { Markdown, parseBlocks, parseInline } from './Markdown'; +import { Markdown } from './Markdown'; const NOTES = [ 'Observer 0.2.0, built from 0123456789ab.', @@ -15,63 +15,6 @@ const NOTES = [ '**Full Changelog**: https://github.com/SensityTech/Observer/compare/v0.1.0...v0.2.0', ].join('\r\n'); -describe('parseBlocks', () => { - it('reads the paragraphs, lists and headings of release notes', () => { - expect(parseBlocks(NOTES).map((block) => block.kind)).toMatchObject([ - 'paragraph', - 'list', - 'heading', - 'list', - 'paragraph', - ]); - }); - - it('keeps a fenced block as it is, and joins the lines of a paragraph', () => { - const blocks = parseBlocks('one\ntwo\n\n```\n# not a heading\n```\n1. first\n2. second'); - - expect(blocks).toMatchObject([ - { kind: 'paragraph', text: 'one two' }, - { kind: 'code', text: '# not a heading' }, - { kind: 'list', ordered: true, items: [{ text: 'first' }, { text: 'second' }] }, - ]); - }); -}); - -describe('parseInline', () => { - it('reads code, emphasis and links', () => { - expect(parseInline('**Full** `code` *mild* [docs](https://example.com/a)')).toMatchObject([ - { kind: 'strong', children: [{ kind: 'text', text: 'Full' }] }, - { kind: 'text', text: ' ' }, - { kind: 'code', text: 'code' }, - { kind: 'text', text: ' ' }, - { kind: 'em', children: [{ kind: 'text', text: 'mild' }] }, - { kind: 'text', text: ' ' }, - { - kind: 'link', - href: 'https://example.com/a', - children: [{ kind: 'text', text: 'docs' }], - }, - ]); - }); - - it('never makes a link of an address that is not http or https', () => { - expect(parseInline('[click](javascript:alert(1))')).toMatchObject([{ kind: 'text', text: 'click)' }]); - expect(parseInline('[file](file:///etc/passwd)')).toMatchObject([{ kind: 'text', text: 'file' }]); - }); - - it('leaves a snake_case name alone and a bare address without its last stop', () => { - expect(parseInline('observer_bridge and https://example.com/x.')).toMatchObject([ - { kind: 'text', text: 'observer_bridge and ' }, - { - kind: 'link', - href: 'https://example.com/x', - children: [{ kind: 'text', text: 'https://example.com/x' }], - }, - { kind: 'text', text: '.' }, - ]); - }); -}); - describe('Markdown', () => { it('draws a pull request and a comparison on GitHub as GitHub names them', () => { render(); @@ -91,4 +34,14 @@ describe('Markdown', () => { expect(container.querySelector('b')).toBeNull(); expect(container.textContent).toContain(''); }); + + it('draws a table, and a link to a place beside the notes as its words', () => { + const { container } = render( + , + ); + + expect(container.querySelector('th:last-child')?.textContent).toBe('For'); + expect(container.querySelector('td:last-child')?.textContent).toBe('Windows'); + expect(container.querySelector('a')).toBeNull(); + }); }); diff --git a/web/src/updates/Markdown.tsx b/web/src/updates/Markdown.tsx index 91fd8433..45d410f4 100644 --- a/web/src/updates/Markdown.tsx +++ b/web/src/updates/Markdown.tsx @@ -1,9 +1,12 @@ import { Fragment, type ReactNode } from 'react'; +import { table } from '../components/table'; +import { parseBlocks, parseInline, type Block, type Inline } from '../lib/markdown'; + /** * Release notes as GitHub writes them, drawn as React elements and never as HTML: headings, - * lists, paragraphs, code, emphasis and links. A link leads only to an http or https address; a - * pull request, an issue or a comparison on GitHub reads as GitHub shows it, `#45`. + * lists, paragraphs, code, tables, emphasis and links. A link leads only to an http or https + * address; a pull request, an issue or a comparison on GitHub reads as GitHub shows it, `#45`. */ export function Markdown({ source }: { source: string }) { return ( @@ -15,200 +18,6 @@ export function Markdown({ source }: { source: string }) { ); } -/** Each part carries its place in the notes as its key: the notes never move once read. */ -export type Block = { key: string } & ( - | { kind: 'heading'; level: number; text: string } - | { kind: 'list'; ordered: boolean; items: { key: string; text: string }[] } - | { kind: 'code'; text: string } - | { kind: 'quote'; text: string } - | { kind: 'rule' } - | { kind: 'paragraph'; text: string } -); - -export type Inline = { key: string } & ( - | { kind: 'text'; text: string } - | { kind: 'code'; text: string } - | { kind: 'strong'; children: Inline[] } - | { kind: 'em'; children: Inline[] } - | { kind: 'link'; href: string; children: Inline[] } -); - -type Unkeyed = T extends unknown ? Omit : never; - -const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/; -const BULLET = /^\s{0,3}[-*+]\s+(.*)$/; -const NUMBERED = /^\s{0,3}\d{1,9}[.)]\s+(.*)$/; -const FENCE = /^\s{0,3}(```|~~~)/; -const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; -const QUOTE = /^\s{0,3}>\s?(.*)$/; - -export function parseBlocks(source: string): Block[] { - const lines = source.replace(/\r\n?/g, '\n').split('\n'); - const blocks: Block[] = []; - const add = (block: Unkeyed) => blocks.push({ ...block, key: `b${blocks.length}` } as Block); - let paragraph: string[] = []; - const flush = () => { - if (paragraph.length > 0) { - add({ kind: 'paragraph', text: paragraph.join(' ') }); - paragraph = []; - } - }; - for (let at = 0; at < lines.length; at += 1) { - const line = lines[at] ?? ''; - if (line.trim() === '') { - flush(); - continue; - } - const fence = FENCE.exec(line); - if (fence !== null) { - flush(); - const marker = fence[1] ?? '```'; - const code: string[] = []; - at += 1; - while (at < lines.length && !(lines[at] ?? '').trimStart().startsWith(marker)) { - code.push(lines[at] ?? ''); - at += 1; - } - add({ kind: 'code', text: code.join('\n') }); - continue; - } - const heading = HEADING.exec(line); - if (heading !== null) { - flush(); - add({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' }); - continue; - } - if (RULE.test(line)) { - flush(); - add({ kind: 'rule' }); - continue; - } - const item = BULLET.exec(line) ?? NUMBERED.exec(line); - if (item !== null) { - flush(); - const ordered = NUMBERED.test(line) && !BULLET.test(line); - const text = item[1] ?? ''; - const last = blocks.at(-1); - if (last?.kind === 'list' && last.ordered === ordered) { - last.items.push({ key: `${last.key}.${last.items.length}`, text }); - } else { - add({ kind: 'list', ordered, items: [{ key: `b${blocks.length}.0`, text }] }); - } - continue; - } - const quote = QUOTE.exec(line); - if (quote !== null) { - flush(); - const text = quote[1] ?? ''; - const last = blocks.at(-1); - if (last?.kind === 'quote') { - last.text = `${last.text} ${text}`; - } else { - add({ kind: 'quote', text }); - } - continue; - } - // A line that carries on the list item above it. - const last = blocks.at(-1); - if (paragraph.length === 0 && last?.kind === 'list' && /^\s{2,}\S/.test(line)) { - const carried = last.items.at(-1); - if (carried !== undefined) { - carried.text = `${carried.text} ${line.trim()}`; - } - continue; - } - paragraph.push(line.trim()); - } - flush(); - return blocks; -} - -const CODE = /^`([^`]+)`/; -const LINK = /^\[([^\]]+)\]\(([^)\s]+)\)/; -const ANGLED = /^<(https?:\/\/[^>\s]+)>/; -const BARE = /^https?:\/\/[^\s<>]+/; -const STRONG = /^(\*\*|__)(?=\S)(.+?)(?<=\S)\1/; -const EM = /^\*(?=[^\s*])(.+?)(?<=[^\s*])\*/; - -export function parseInline(text: string): Inline[] { - const nodes: Inline[] = []; - const keyed = (node: Unkeyed) => nodes.push({ ...node, key: `i${nodes.length}` } as Inline); - let plain = ''; - const push = (node: Unkeyed) => { - if (plain !== '') { - keyed({ kind: 'text', text: plain }); - plain = ''; - } - keyed(node); - }; - let at = 0; - while (at < text.length) { - const rest = text.slice(at); - const char = text.charAt(at); - let match: RegExpExecArray | null = null; - if (char === '`' && (match = CODE.exec(rest)) !== null) { - push({ kind: 'code', text: match[1] ?? '' }); - } else if (char === '[' && (match = LINK.exec(rest)) !== null) { - const href = safeHref(match[2] ?? ''); - if (href === null) { - plain += match[1] ?? ''; - } else { - push({ kind: 'link', href, children: parseInline(match[1] ?? '') }); - } - } else if (char === '<' && (match = ANGLED.exec(rest)) !== null) { - push(urlLink(match[1] ?? '')); - } else if ( - char === 'h' && - /\w/.test(text[at - 1] ?? ' ') === false && - (match = BARE.exec(rest)) !== null - ) { - // A sentence's last stop or a closing bracket is not part of the address. - const url = match[0].replace(/[.,;:!?'")\]]+$/, ''); - push(urlLink(url)); - at += url.length; - continue; - } else if ((char === '*' || char === '_') && (match = STRONG.exec(rest)) !== null) { - push({ kind: 'strong', children: parseInline(match[2] ?? '') }); - } else if (char === '*' && (match = EM.exec(rest)) !== null) { - push({ kind: 'em', children: parseInline(match[1] ?? '') }); - } - if (match === null) { - plain += char; - at += 1; - } else { - at += match[0].length; - } - } - if (plain !== '') { - keyed({ kind: 'text', text: plain }); - } - return nodes; -} - -/** An http or https address, or null for anything else (`javascript:`, a relative path…). */ -const safeHref = (href: string): string | null => { - try { - const url = new URL(href); - return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null; - } catch { - return null; - } -}; - -const GITHUB_ITEM = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+)$/; -const GITHUB_COMPARE = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/compare\/(\S+)$/; - -const urlLink = (url: string): Unkeyed => { - const href = safeHref(url); - if (href === null) { - return { kind: 'text', text: url }; - } - const item = GITHUB_ITEM.exec(url); - const compare = GITHUB_COMPARE.exec(url); - const text = item !== null ? `#${item[1] ?? ''}` : compare !== null ? (compare[1] ?? url) : url; - return { kind: 'link', href, children: [{ key: 'i0', kind: 'text', text }] }; -}; - function BlockView({ block }: { block: Block }) { switch (block.kind) { case 'heading': @@ -239,6 +48,33 @@ function BlockView({ block }: { block: Block }) { {block.text} ); + case 'table': + return ( +
    + + + + {block.head.map((cell) => ( + + ))} + + + + {block.rows.map((row) => ( + + {row.cells.map((cell) => ( + + ))} + + ))} + +
    + +
    + +
    +
    + ); case 'quote': return (
    @@ -282,6 +118,9 @@ const render = (nodes: Inline[]): ReactNode => ); case 'em': return {render(node.children)}; + // Release notes stand alone: a link to a place beside them leads nowhere here. + case 'local': + return {render(node.children)}; case 'link': return (