diff --git a/compiler-base/src/util/xml_escape.rs b/compiler-base/src/util/escape.rs similarity index 50% rename from compiler-base/src/util/xml_escape.rs rename to compiler-base/src/util/escape.rs index 46a4d40d..c75ebd76 100644 --- a/compiler-base/src/util/xml_escape.rs +++ b/compiler-base/src/util/escape.rs @@ -1,17 +1,38 @@ use std::borrow::Cow; -macro_rules! escape { - ($escaped:ident, $bytes:literal, $s:ident, $i:ident, $len:literal) => { - match &mut $escaped { - None => { - let mut vec = Vec::with_capacity($s.len() + $len); - vec.extend_from_slice(&$s.as_bytes()[0..$i]); - vec.extend_from_slice($bytes); - $escaped = Some(vec); +macro_rules! escape_impl { + ( $s:ident, $($byte:literal => $escaped:literal),* $(,)?) => { + // An ASCII byte always represent a ASCII character + // so it is safe to treat the input as bytes + { + let s = $s; + let mut escaped = None; + for (i, b) in s.bytes().enumerate() { + match b { + $( + $byte => match &mut escaped { + None => { + let mut vec = Vec::with_capacity(s.len() + $escaped.len()); + vec.extend_from_slice(&s.as_bytes()[0..i]); + vec.extend_from_slice($escaped); + escaped = Some(vec); + } + Some(vec) => vec.extend_from_slice($escaped), + } + )* + _ => { + if let Some(vec) = &mut escaped { + vec.push(b); + } + } + } + } + match escaped { + Some(vec) => Cow::Owned(String::from_utf8(vec).unwrap()), + None => Cow::Borrowed(s), } - Some(vec) => vec.extend_from_slice($bytes), } - }; + } } /// Escapes a string for XML. @@ -23,36 +44,32 @@ macro_rules! escape { /// - `"` becomes `"` /// - `'` becomes `'` pub fn xml_escape(s: &str) -> Cow { - // An ASCII byte always represent a ASCII character - // so it is safe to treat the input as bytes - let mut escaped = None; - for (i, b) in s.bytes().enumerate() { - match b { - b'&' => { - escape!(escaped, b"&", s, i, 5); - } - b'<' => { - escape!(escaped, b"<", s, i, 5); - } - b'>' => { - escape!(escaped, b">", s, i, 5); - } - b'\'' => { - escape!(escaped, b"'", s, i, 5); - } - b'"' => { - escape!(escaped, b""", s, i, 5); - } - _ => { - if let Some(vec) = &mut escaped { - vec.push(b); - } - } - } + escape_impl! { + s, + b'&' => b"&", + b'<' => b"<", + b'>' => b">", + b'\'' => b"'", + b'"' => b""", } - match escaped { - Some(vec) => Cow::Owned(String::from_utf8(vec).unwrap()), - None => Cow::Borrowed(s), +} + +/// Escapes a string for HTML attribute value +/// +/// This function escapes the following characters: +/// - `&` becomes `&` +/// - `<` becomes `<` +/// - `>` becomes `>` +/// - `"` becomes `"` +/// - `'` becomes `'` +pub fn html_attr_escape(s: &str) -> Cow { + escape_impl! { + s, + b'&' => b"&", + b'<' => b"<", + b'>' => b">", + b'\'' => b"'", + b'"' => b""", } } diff --git a/compiler-base/src/util/mod.rs b/compiler-base/src/util/mod.rs index 0c415bb9..0d378cec 100644 --- a/compiler-base/src/util/mod.rs +++ b/compiler-base/src/util/mod.rs @@ -2,8 +2,8 @@ mod string_map; pub use string_map::*; -mod xml_escape; -pub use xml_escape::*; +mod escape; +pub use escape::*; // re-exports pub use uni_path::{Component, Path, PathBuf}; diff --git a/docs/src/.vitepress/config.ts b/docs/src/.vitepress/config.ts index 98088cdc..be25e8ea 100644 --- a/docs/src/.vitepress/config.ts +++ b/docs/src/.vitepress/config.ts @@ -21,7 +21,7 @@ export default defineConfig({ { rel: "icon", href: "/static/celer-3.svg", type: "image/svg+xml" }, ], // Color - ["meta", { property: "theme-color", content: "rgb(173,255,184)" }], + ["meta", { property: "theme-color", content: "#adfeb8" }], // Open Graph [ "meta", diff --git a/docs/src/route/publish.md b/docs/src/route/publish.md index 33e7e94e..93b01a8f 100644 --- a/docs/src/route/publish.md +++ b/docs/src/route/publish.md @@ -1,20 +1,115 @@ # Publish the Route -:::info -Incomplete. Tracked by [TODO #26](https://github.com/Pistonite/celer/issues/26) +To publish or share your route, you need to upload it to [GitHub](https://github.com). +Everyone can then view it on Celer. +:::tip +You need a GitHub account for this. If you are already familiar with Git, you can skip to the bottom of the page which tells you how to view the route +once uploaded. ::: -To publish or share your route, you need to upload it to GitHub. Everyone can then use Celer to view it. You need a GitHub account for this. -## How it works (Git basics) -1. You will create a repository on GitHub, which will become a copy of your project on GitHub. -2. Upload (push) your local folder to the repository. -3. When someone views a published route on Celer, Celer queries GitHub for the route files -4. When you want to update the route, change the files and push those changes to GitHub. -5. Everyone will see the updated route on Celer. +If you aren't familiar with git, don't worry. This page will guide you through all the steps. The general idea is: + +1. You create a so-called repository (repo for short) on GitHub that will store the project. +2. You create a folder on your PC that is linked to the repository on GitHub. This process is known as "clone". +3. You move your project files inside the cloned repository. +4. You upload those files to the repository on GitHub. This process is known as "push". +5. Make future updates in the local repository and push again to upload the changes. + ## Creating the repository 1. Go to https://github.com/new to start creating a new repository 2. Enter a name under `Repository name`. Note the following: - It is the best for your repository name to only contain alphanumeric characters (`a-z`, `A-Z` and `0-9`), `_` and `-`. Special characters like `%` or unicode characters will cause inconvienience. +3. Enter a description (e.g. "My Awesome Celer Project") 3. Make sure the new repository will be `public`. Celer cannot access your private repositories. -4. +4. Check "Add a README file". +5. Click "Create repository" + +## Cloning the repository +You can either clone the repository with the `git` CLI tool, or use a GUI tool like GitHub Desktop. +It is recommended to use GitHub Desktop if you don't know how or aren't comfortable running commands in a terminal. + +### With GIT Command line tool +:::tip +If you are on windows, you can install `git` [here](https://git-scm.com/download/win) +::: +With `git` installed, run the following command in the directory where you want to store all your repos. +Replace `YOUR_USER_NAME` with your GitHub username and `YOUR_REPO_NAME` with the name you entered in step 2 above. + +```bash +git clone git@github.com:YOUR_USER_NAME/YOUR_REPO_NAME +``` + +### With GitHub Desktop +Install GitHub Desktop from [here](https://desktop.github.com/). Then open it and sign in with your GitHub account. + +1. Click on "Clone a repository from the internet" +2. Search for the repository you just created +3. Choose a path on your PC where you want the repo to be cloned. +4. Click "Clone" + +## Move your project inside the repository +Once the repo is cloned, you should see a directory `.git` inside it on your PC and a `README.md` file. + +You can simply copy and paste the project files you have been editing to the repository. The `project.yaml` +file should be at the root, next to the `README.md` file. + +## Push your files +### With GIT Command line tool +First stage your changes +```bash +git add . +``` +Then commit them with a message +```bash +git commit -m "example message" +``` +Then push the changes +```bash +git push +``` + +### With GitHub Desktop +1. In GitHub Desktop, it should show the local changes you made in the "Changes" panel on the left. +Select the files you want to upload. +2. At the bottom of the changes panel, enter a short message describing the change. This is known as a commit message. +3. Click "Commit to main" +4. Now the changes panel should say "0 changed files" +5. On the top, you should see something like this: + ![image of push origin](https://cdn.discordapp.com/attachments/951389021114871819/1209290318076444723/image.png?ex=65e6625f&is=65d3ed5f&hm=dab6cefc2abbd3f7796c8298cdb50bd6299dbf4c39ef30af5f8452e86cc43bba&) +6. Click that, and your commits are now uploaded. You can go to the repository on GitHub to confirm. + +## Viewing the route on Celer +To view the route on celer, go to the URL below. Replace the placeholders with your GitHub username and repo name +``` +scheme://celer.placeholder.domain/view/YOUR_USER_NAME/YOUR_REPO_NAME +``` + +### Viewing entry point +If you configured the project as a monorepo with the `entry-points` property, as described [here](./file-structure.md), +the URL above will take you to the `default` entry point. You can add an entry point to the URL to view a particular entry point. + +For example, say your root `project.yaml` has: +```yaml +entry-points: + my-sub-project: /path/to/project.yaml +``` +You can view `my-sub-project` as +``` +scheme://celer.placeholder.domain/view/YOUR_USER_NAME/YOUR_REPO_NAME/my-sub-project +``` + +You can also refer to the target `project.yaml` directly: +``` +scheme://celer.placeholder.domain/view/YOUR_USER_NAME/YOUR_REPO_NAME/path/to/project.yaml +``` + +### Viewing specific branch/commit/tag +You can add `:BRANCH` to the end of the URL to view the route at a particular branch, commit, or tag. The default is the `main` branch when you don't specify one. + +For example, let's say you created a branch `v1.2`, you can refer to this branch as +``` +scheme://celer.placeholder.domain/view/YOUR_USER_NAME/YOUR_REPO_NAME:v1.2 +scheme://celer.placeholder.domain/view/YOUR_USER_NAME/YOUR_REPO_NAME/ENTRY_POINT:v1.2 +``` +This can be useful for versioning your route diff --git a/docs/src/toolbar.md b/docs/src/toolbar.md index f47485a2..359090b8 100644 --- a/docs/src/toolbar.md +++ b/docs/src/toolbar.md @@ -15,6 +15,7 @@ These options are disabled until a document is loaded - `Jump to section`: Scroll the document to a particular section. - `View diagnostics`: Quickly jump to a line where there is an error, warning, or info message. - `Export`: Export data from the document, such as split files. See [Export](./export.md) for more details. +- `Reload Document`: Reload the document from server. ## Map Options :::tip diff --git a/server/Cargo.toml b/server/Cargo.toml index b1d15b4e..b643d020 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,6 +10,19 @@ path = "../compiler-core" features = ["native"] default-features = false +# tower +[dependencies.tower] +version = "0.4.13" + +[dependencies.tower-http] +version = "0.5.1" +features = [ + "fs", + "trace", + "compression-gzip", + "set-header" +] + [dependencies] axum = "0.7.4" axum-macros = "0.4.1" @@ -18,8 +31,6 @@ envconfig = "0.10.0" futures = "0.3.28" http-body = "0.4.5" tokio = { version = "1.36.0", features=["macros", "rt-multi-thread"] } -tower = "0.4.13" -tower-http = { version = "0.5.1", features = ["fs", "trace", "compression-gzip"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["ansi"] } flate2 = "1.0.28" diff --git a/server/src/api/compile.rs b/server/src/api/compile.rs index fec7a633..95c6be67 100644 --- a/server/src/api/compile.rs +++ b/server/src/api/compile.rs @@ -1,5 +1,7 @@ +//! The `/compile` API endpoint. + use axum::extract::Path; -use axum::routing::get; +use axum::routing; use axum::{Json, Router}; use instant::Instant; use serde::{Deserialize, Serialize}; @@ -9,12 +11,15 @@ use tower_http::compression::CompressionLayer; use crate::compiler; -pub fn init_compile_api() -> Router { +pub fn init_api() -> Router { Router::new() - .route("/:owner/:repo/:reference", get(compile_owner_repo_ref)) + .route( + "/:owner/:repo/:reference", + routing::get(compile_owner_repo_ref), + ) .route( "/:owner/:repo/:reference/*path", - get(compile_owner_repo_ref_path), + routing::get(compile_owner_repo_ref_path), ) .layer(ServiceBuilder::new().layer(CompressionLayer::new())) } diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index c968f365..0781f3cc 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -1,17 +1,26 @@ +use std::io; + use axum::{routing, Router}; use tracing::info; use crate::env; mod compile; +mod view; -pub fn init_api(router: Router) -> Router { +pub fn init_api(router: Router, app_dir: &str) -> Result { info!("initializing api routes"); - router.nest("/api/v1", init_api_v1()) + let router = router + .nest("/api/v1", init_api_v1()?) + .nest("/view", view::init_api(app_dir)?); + + Ok(router) } -pub fn init_api_v1() -> Router { - Router::new() +pub fn init_api_v1() -> Result { + let router = Router::new() .route("/version", routing::get(|| async { env::version() })) - .nest("/compile", compile::init_compile_api()) + .nest("/compile", compile::init_api()); + + Ok(router) } diff --git a/server/src/api/view.rs b/server/src/api/view.rs new file mode 100644 index 00000000..c2e6acdf --- /dev/null +++ b/server/src/api/view.rs @@ -0,0 +1,228 @@ +//! The `/view` API endpoint to serve the celer viewer HTML with meta tags injected. + +use std::fs; +use std::io; +use std::sync::OnceLock; + +use axum::extract::Path; +use axum::http::header; +use axum::http::{HeaderValue, StatusCode}; +use axum::routing; +use axum::Router; +use cached::proc_macro::cached; +use tower::ServiceBuilder; +use tower_http::compression::CompressionLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use tracing::error; + +use celerc::env; +use celerc::util; + +use crate::compiler; + +pub fn init_api(app_dir: &str) -> Result { + init_view_html(app_dir)?; + + let router = Router::new() + .route("/:owner/:repo", routing::get(view_owner_repo)) + .route("/:owner/:repo/*path", routing::get(view_owner_repo_path)) + .layer( + ServiceBuilder::new() + .layer(CompressionLayer::new()) + .layer(SetResponseHeaderLayer::overriding( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html;charset=utf-8"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("public,max-age=600"), + )) + .layer(SetResponseHeaderLayer::overriding( + header::EXPIRES, + HeaderValue::from_static("600"), + )), + ); + + Ok(router) +} + +const SERVER_INJECTED_TAGS: &str = ""; + +static VIEW_HTML_HEAD: OnceLock = OnceLock::new(); +fn get_head() -> Result<&'static str, StatusCode> { + match VIEW_HTML_HEAD.get() { + Some(x) => Ok(x), + None => { + error!("View html head not initialized!"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +static VIEW_HTML_TAIL: OnceLock = OnceLock::new(); +fn get_tail() -> Result<&'static str, StatusCode> { + match VIEW_HTML_TAIL.get() { + Some(x) => Ok(x), + None => { + error!("View html tail not initialized!"); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +fn init_view_html(app_dir: &str) -> Result<(), io::Error> { + let view_path = std::path::Path::new(app_dir) + .join("view.html") + .canonicalize()?; + let view_html = fs::read_to_string(view_path)?; + + let mut split = view_html.split(SERVER_INJECTED_TAGS); + match split.next() { + Some(head) => { + VIEW_HTML_HEAD.get_or_init(|| head.to_string()); + } + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "view.html missing head".to_string(), + )); + } + }; + match split.next() { + Some(tail) => { + VIEW_HTML_TAIL.get_or_init(|| tail.to_string()); + } + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "view.html missing tail".to_string(), + )); + } + }; + if split.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "view.html has multiple inject tags".to_string(), + )); + } + + Ok(()) +} + +async fn view_owner_repo( + Path((owner, repo)): Path<(String, String)>, +) -> Result { + // try to separate repo:reference:remaining + let mut repo_parts = repo.splitn(3, ':'); + let repo = match repo_parts.next() { + Some(repo) => repo, + None => return view_fallback(), + }; + let reference = repo_parts.next().unwrap_or("main"); + view_internal(&owner, repo, reference, "").await +} + +async fn view_owner_repo_path( + Path((owner, repo, path)): Path<(String, String, String)>, +) -> Result { + // try to separate path:reference:remaining + let mut path_parts = path.splitn(3, ':'); + let path = path_parts.next().unwrap_or(""); + let reference = path_parts.next().unwrap_or("main"); + view_internal(&owner, &repo, reference, path).await +} + +#[cached( + size = 128, + time = 600, + key = "String", + convert = r#"{ format!("{owner}/{repo}/{reference}/{path}") }"#, + result = true +)] +async fn view_internal( + owner: &str, + repo: &str, + reference: &str, + path: &str, +) -> Result { + let mut builder = compiler::new_context_builder(owner, repo, Some(reference)); + if !path.is_empty() { + builder = builder.entry_point(Some(path.to_string())); + } + + let metadata = match builder.get_metadata().await { + Err(e) => { + error!("Error getting metadata for project {owner}/{repo}/{reference}/{path}: {e}"); + return view_fallback(); + } + Ok(metadata) => metadata, + }; + let title = &metadata.title; + let version = &metadata.version; + + let title = if title.is_empty() { + "Celer Viewer".to_string() + } else if version.is_empty() { + title.to_string() + } else { + format!("{title} - {version}") + }; + + let description = { + let mut repo_desc = format!("{owner}/{repo}"); + if !path.is_empty() { + repo_desc.push('/'); + repo_desc.push_str(path); + } + if !reference.is_empty() { + repo_desc.push_str(" ("); + repo_desc.push_str(reference); + repo_desc.push(')'); + } + format!("View {repo_desc} on Celer") + }; + + let view_url = { + let mut url = format!("{}/view/{owner}/{repo}", env::get_site_origin()); + if !path.is_empty() { + url.push('/'); + url.push_str(path); + } + if !reference.is_empty() && reference != "main" { + url.push(':'); + url.push_str(reference); + } + url + }; + let title_tag = format!( + "", + util::html_attr_escape(&title) + ); + let description_tag = format!( + "", + util::html_attr_escape(&description) + ); + let url_tag = format!( + "", + util::html_attr_escape(&view_url) + ); + + let head = get_head()?; + let tail = get_tail()?; + + let html = format!( + "{head} + {title_tag} + {description_tag} + {url_tag} + {tail}" + ); + + Ok(html) +} + +fn view_fallback() -> Result { + let head = get_head()?; + let tail = get_tail()?; + Ok(format!("{head}{tail}")) +} diff --git a/server/src/compiler/loader.rs b/server/src/compiler/loader.rs index 8cc17b67..6224765d 100644 --- a/server/src/compiler/loader.rs +++ b/server/src/compiler/loader.rs @@ -1,5 +1,6 @@ use std::io::Read; +use axum::http::header; use cached::{Cached, TimedSizedCache}; use flate2::read::GzDecoder; use once_cell::sync::Lazy; @@ -46,8 +47,8 @@ impl ServerResourceLoader { let response = self .http_client .get(url) - .header("User-Agent", "celery") - .header("Accept-Encoding", "gzip") + .header(header::USER_AGENT.as_str(), "celery") + .header(header::ACCEPT_ENCODING.as_str(), "gzip") .send() .await .map_err(|e| { @@ -68,7 +69,7 @@ impl ServerResourceLoader { } // check Content-Encoding - let is_gzipped = match response.headers().get("Content-Encoding") { + let is_gzipped = match response.headers().get(header::CONTENT_ENCODING.as_str()) { Some(encoding) => { if encoding != "gzip" { let encoding = encoding.to_str().unwrap_or("unknown"); diff --git a/server/src/main.rs b/server/src/main.rs index ff85f64d..be502a2a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -54,7 +54,7 @@ async fn main() -> Result<(), Box> { &env.app_dir, &["/celerc", "/static", "/assets", "/themes"], )?; - let router = api::init_api(router); + let router = api::init_api(router, &env.app_dir)?; let router = router.layer( tower_http::trace::TraceLayer::new_for_http() diff --git a/web-client/index.html b/web-client/index.html index ff073b01..617416ce 100644 --- a/web-client/index.html +++ b/web-client/index.html @@ -1,45 +1,46 @@ - - - - + + + + - + - - - - - + + - + - Celer Editor - - - - + + + + +
-
+
-
- Celer -
+
Celer
diff --git a/web-client/src/core/doc/index.ts b/web-client/src/core/doc/index.ts index 1dad2fbf..312423c4 100644 --- a/web-client/src/core/doc/index.ts +++ b/web-client/src/core/doc/index.ts @@ -3,6 +3,7 @@ //! Document viewer system export * from "./export"; +export * from "./loader"; export * from "./state"; export * from "./utils"; export * as documentReducers from "./docReducers"; diff --git a/web-client/src/core/doc/loader.ts b/web-client/src/core/doc/loader.ts new file mode 100644 index 00000000..14af1566 --- /dev/null +++ b/web-client/src/core/doc/loader.ts @@ -0,0 +1,124 @@ +//! Utilities for loading/requesting document from server + +import type { ExpoContext } from "low/celerc"; +import { fetchAsJson, getApiUrl } from "low/fetch"; +import { console, wrapAsync } from "low/utils"; + +export type LoadDocumentResult = + | { + type: "success"; + data: ExpoContext; + } + | { + type: "failure"; + data: string; + help?: string; + }; + +const HELP_URL = "/docs/route/publish#viewing-the-route-on-celer"; + +/// Load the document based on the current URL (window.location.pathname) +/// +/// The path should be /view/{owner}/{repo}/{path}:{reference} +export async function loadDocumentFromCurrentUrl(): Promise { + const pathname = window.location.pathname; + if (!pathname.startsWith("/view")) { + return createLoadError( + "Invalid document URL. Please double check you have the correct URL.", + HELP_URL, + ); + } + const parts = pathname.substring(6).split("/").filter(Boolean); + // parts[0] is owner + // parts[1] is repo + // parts[2:] is path + // last is path:reference + if (parts.length < 2) { + return createLoadError( + "Invalid document reference. Please double check you have the correct URL.", + HELP_URL, + ); + } + + const [owner, repo, ...rest] = parts; + if (!owner || !repo) { + return createLoadError( + "Invalid document reference. Please double check you have the correct URL.", + HELP_URL, + ); + } + let reference = "main"; + let realRepo = repo; + if (rest.length > 0) { + const [last, ref] = rest[rest.length - 1].split(":", 2); + rest[rest.length - 1] = last; + if (ref) { + reference = ref; + } + } else { + // :reference might be in repo + const [last, ref] = repo.split(":", 2); + realRepo = last; + if (ref) { + reference = ref; + } + } + const path = rest.join("/"); + return await loadDocument(owner, realRepo, reference, path); +} + +function createLoadError( + message: string, + help: string | undefined, +): LoadDocumentResult { + return { + type: "failure", + data: message, + help, + }; +} + +export async function loadDocument( + owner: string, + repo: string, + reference: string, + path: string | undefined, +): Promise { + console.info(`loading document: ${owner}/${repo}/${reference} ${path}`); + const startTime = performance.now(); + let url = `/compile/${owner}/${repo}/${reference}`; + if (path) { + url += `/${path}`; + } + const result = await wrapAsync(async () => + fetchAsJson(getApiUrl(url)), + ); + if (result.isErr()) { + return createLoadError( + "There was an error loading the document from the server.", + undefined, + ); + } + const response = result.inner(); + const elapsed = Math.round(performance.now() - startTime); + console.info(`received resposne in ${elapsed}ms`); + if (response.type === "success") { + injectLoadTime(response.data, elapsed); + } else { + if (!response.help) { + response.help = HELP_URL; + } + } + + return result.inner(); +} + +function injectLoadTime(doc: ExpoContext, ms: number) { + // in case the response from server is invalid, we don't want to crash the app + try { + doc.execDoc.project.stats["Loaded In"] = `${ms}ms`; + } catch (e) { + console.info("failed to inject load time"); + console.error(e); + } +} diff --git a/web-client/src/core/kernel/Kernel.ts b/web-client/src/core/kernel/Kernel.ts index 04fcbd89..832bc37a 100644 --- a/web-client/src/core/kernel/Kernel.ts +++ b/web-client/src/core/kernel/Kernel.ts @@ -17,13 +17,14 @@ import { getDefaultSplitTypes, getSplitExportPluginConfigs, isRecompileNeeded, + loadDocumentFromCurrentUrl, } from "core/doc"; import { ExpoDoc, ExportRequest } from "low/celerc"; -import { console, Logger, isInDarkMode } from "low/utils"; +import { console, Logger, isInDarkMode, sleep } from "low/utils"; import type { FileSys, FsResult } from "low/fs"; import type { CompilerKernel } from "./compiler"; -import type { EditorKernel } from "./editor"; +import type { EditorKernel, KernelAccess } from "./editor"; import { KeyMgr } from "./KeyMgr"; import { WindowMgr } from "./WindowMgr"; import { AlertMgr } from "./AlertMgr"; @@ -39,7 +40,7 @@ type InitUiFunction = ( /// The kernel owns all global resources like the redux store. /// It is also responsible for mounting react to the DOM and /// handles the routing. -export class Kernel { +export class Kernel implements KernelAccess { /// The logger private log: Logger; /// The store @@ -92,12 +93,7 @@ export class Kernel { watchAll(async (newVal: AppState, oldVal: AppState) => { if (await isRecompileNeeded(newVal, oldVal)) { console.info("reloading document due to state change..."); - if (viewSelector(newVal).stageMode === "edit") { - const compiler = await this.getCompiler(); - compiler.compile(); - } else { - // TODO #26: reload doc from server - } + await this.reloadDocument(); } }), ); @@ -124,12 +120,16 @@ export class Kernel { this.log.info("initializing stage..."); const path = window.location.pathname; if (path === "/edit") { + document.title = "Celer Editor"; const { initCompiler } = await import("./compiler"); const compiler = initCompiler(this.store); this.compiler = compiler; this.store.dispatch(viewActions.setStageMode("edit")); } else { + setTimeout(() => { + this.reloadDocument(); + }, 0); this.store.dispatch(viewActions.setStageMode("view")); } } @@ -299,9 +299,13 @@ export class Kernel { console.info("project opened."); } - public async compile() { - const compiler = await this.getCompiler(); - compiler.compile(); + public async reloadDocument() { + if (viewSelector(this.store.getState()).stageMode === "edit") { + const compiler = await this.getCompiler(); + compiler.compile(); + return; + } + await this.reloadDocumentFromServer(); } public async closeFileSys() { @@ -365,4 +369,66 @@ export class Kernel { }; } } + + /// Reload the document from the server based on the current URL + private async reloadDocumentFromServer() { + this.store.dispatch(documentActions.setDocument(undefined)); + // let UI update + await sleep(0); + // show progress spinner if reload takes longer than 200ms + const handle = setTimeout(() => { + this.store.dispatch(viewActions.setCompileInProgress(true)); + }, 200); + + let retry = true; + while (retry) { + this.log.info("reloading document from server"); + const result = await loadDocumentFromCurrentUrl(); + if (result.type === "failure") { + this.store.dispatch(documentActions.setDocument(undefined)); + this.log.info("failed to load document from server"); + this.log.error(result.data); + retry = await this.getAlertMgr().show({ + title: "Failed to load route", + message: result.data, + learnMoreLink: result.help, + okButton: "Retry", + cancelButton: "Cancel", + }); + if (!retry) { + await this.alertMgr.show({ + title: "Load cancelled", + message: + 'You can retry at any time by refreshing the page, or by clicking "Reload Document" from the toolbar.', + okButton: "Got it", + cancelButton: "", + }); + break; + } + this.log.warn("retrying in 1s..."); + await sleep(1000); + continue; + } + this.log.info("received document from server"); + const doc = result.data; + try { + const { title, version } = doc.execDoc.project; + if (!title) { + document.title = "Celer Viewer"; + } else if (!version) { + document.title = title; + } else { + document.title = `${title} - ${version}`; + } + } catch (e) { + this.log.warn("failed to set document title"); + this.log.error(e); + document.title = "Celer Viewer"; + } + this.store.dispatch(documentActions.setDocument(doc)); + break; + } + clearTimeout(handle); + this.store.dispatch(viewActions.setCompileInProgress(false)); + } } diff --git a/web-client/src/core/kernel/editor/ExternalEditorKernel.ts b/web-client/src/core/kernel/editor/ExternalEditorKernel.ts index 42232540..c7e8b0e7 100644 --- a/web-client/src/core/kernel/editor/ExternalEditorKernel.ts +++ b/web-client/src/core/kernel/editor/ExternalEditorKernel.ts @@ -11,7 +11,8 @@ import { import { IdleMgr, Yielder, createYielder, allocOk } from "low/utils"; import { EditorKernel } from "./EditorKernel"; -import { EditorLog, KernelAccess, toFsPath } from "./utils"; +import { EditorLog, toFsPath } from "./utils"; +import { KernelAccess } from "./KernelAccess"; EditorLog.info("loading external editor kernel"); @@ -65,7 +66,7 @@ class ExternalEditorKernel implements EditorKernel, FileAccess { if (changed) { this.lastCompiledTime = Date.now(); this.notifyActivity(); - this.kernelAccess.compile(); + this.kernelAccess.reloadDocument(); } } diff --git a/web-client/src/core/kernel/editor/KernelAccess.ts b/web-client/src/core/kernel/editor/KernelAccess.ts new file mode 100644 index 00000000..feebd4a1 --- /dev/null +++ b/web-client/src/core/kernel/editor/KernelAccess.ts @@ -0,0 +1,5 @@ +/// Interface for editor to access kernel functions +export interface KernelAccess { + /// Reload the document, either through compiler or from server + reloadDocument(): Promise; +} diff --git a/web-client/src/core/kernel/editor/WebEditorKernel.ts b/web-client/src/core/kernel/editor/WebEditorKernel.ts index c69738d7..0071930d 100644 --- a/web-client/src/core/kernel/editor/WebEditorKernel.ts +++ b/web-client/src/core/kernel/editor/WebEditorKernel.ts @@ -19,8 +19,9 @@ import { FileAccess, FileSys, FsResult } from "low/fs"; import { isInDarkMode, IdleMgr, DOMId } from "low/utils"; import { EditorKernel } from "./EditorKernel"; -import { EditorLog, KernelAccess, toFsPath } from "./utils"; +import { EditorLog, toFsPath } from "./utils"; import { FileMgr } from "./FileMgr"; +import { KernelAccess } from "./KernelAccess"; EditorLog.info("loading web editor kernel"); @@ -161,7 +162,7 @@ class WebEditorKernel implements EditorKernel { const result = await this.idleMgr.pauseIdleScope(async () => { return await this.fileMgr.loadChangesFromFs(); }); - this.kernelAccess.compile(); + this.kernelAccess.reloadDocument(); return result; } @@ -229,7 +230,7 @@ class WebEditorKernel implements EditorKernel { // do this last so we can get the latest save status after auto-save this.fileMgr.updateDirtyFileList(unsavedFiles); if (this.shouldRecompile) { - this.kernelAccess.compile(); + this.kernelAccess.reloadDocument(); this.shouldRecompile = false; } } diff --git a/web-client/src/core/kernel/editor/index.ts b/web-client/src/core/kernel/editor/index.ts index 2ce4e583..0f9a4cba 100644 --- a/web-client/src/core/kernel/editor/index.ts +++ b/web-client/src/core/kernel/editor/index.ts @@ -2,4 +2,5 @@ import { EditorLog } from "./utils"; EditorLog.info("loading editor module"); export * from "./EditorKernel"; +export * from "./KernelAccess"; export * from "./initEditor"; diff --git a/web-client/src/core/kernel/editor/initEditor.ts b/web-client/src/core/kernel/editor/initEditor.ts index b13515ee..de9c3525 100644 --- a/web-client/src/core/kernel/editor/initEditor.ts +++ b/web-client/src/core/kernel/editor/initEditor.ts @@ -1,7 +1,7 @@ import { AppStore, settingsSelector } from "core/store"; import { FileSys } from "low/fs"; -import { KernelAccess } from "./utils"; +import { KernelAccess } from "./KernelAccess"; import { EditorKernel } from "./EditorKernel"; declare global { diff --git a/web-client/src/core/kernel/editor/utils.ts b/web-client/src/core/kernel/editor/utils.ts index 1d537115..28968d4c 100644 --- a/web-client/src/core/kernel/editor/utils.ts +++ b/web-client/src/core/kernel/editor/utils.ts @@ -23,9 +23,3 @@ export const detectLanguageByFileName = (fileName: string): string => { } return "text"; }; - -/// Interface for editor to access kernel functions -export interface KernelAccess { - /// Trigger the compiler - compile(): Promise; -} diff --git a/web-client/src/low/fetch.ts b/web-client/src/low/fetch.ts index 970d7540..87508273 100644 --- a/web-client/src/low/fetch.ts +++ b/web-client/src/low/fetch.ts @@ -1,16 +1,22 @@ import { console, sleep } from "./utils"; -export const fetchAsBytes = (url: string): Promise => { +export function fetchAsBytes(url: string): Promise { return doFetch(url, async (response) => { const buffer = await response.arrayBuffer(); return new Uint8Array(buffer); }); -}; +} -export const fetchAsString = (url: string): Promise => { +export function fetchAsString(url: string): Promise { return doFetch(url, (response) => { return response.text(); }); +} + +export const fetchAsJson = (url: string): Promise => { + return doFetch(url, (response) => { + return response.json(); + }); }; const API_PREFIX = "/api/v1"; diff --git a/web-client/src/ui/toolbar/Header.tsx b/web-client/src/ui/toolbar/Header.tsx index d9775a98..256b288e 100644 --- a/web-client/src/ui/toolbar/Header.tsx +++ b/web-client/src/ui/toolbar/Header.tsx @@ -30,6 +30,8 @@ import React, { PropsWithChildren, useMemo } from "react"; import { useSelector } from "react-redux"; import { documentSelector, settingsSelector, viewSelector } from "core/store"; +import type { ExecDoc } from "low/celerc"; + import { getHeaderControls } from "./getHeaderControls"; import { HeaderControlList } from "./util"; import { useHeaderStyles } from "./styles"; @@ -43,11 +45,9 @@ type HeaderProps = { /// The header component export const Header: React.FC = ({ toolbarAnchor }) => { const { document } = useSelector(documentSelector); - const { stageMode } = useSelector(viewSelector); + const { stageMode, compileInProgress } = useSelector(viewSelector); const { editorMode } = useSelector(settingsSelector); - const title = - document?.project.title ?? - (stageMode === "edit" ? "Celer Editor" : "Loading..."); + const title = useTitle(stageMode, document, compileInProgress); const headerControls = useMemo(() => { return getHeaderControls(stageMode, editorMode); @@ -105,6 +105,28 @@ export const Header: React.FC = ({ toolbarAnchor }) => { ); }; +function useTitle( + stageMode: string, + document: ExecDoc | undefined, + compileInProgress: boolean, +) { + if (document) { + // if document is loaded, return the document title + return document?.project.title; + } + if (stageMode === "edit") { + // if in edit mode, return the editor title + return "Celer Editor"; + } + // viewer + if (compileInProgress) { + return "Loading..."; + } + // if in view mode, but is not loading (e.g. user cancelled the loading) + // return the viewer title + return "Celer Viewer"; +} + /// Wrapper for ToolbarDivider in the overflow /// /// The divider is only visible when the group is visible diff --git a/web-client/src/ui/toolbar/CompileProject.tsx b/web-client/src/ui/toolbar/ReloadDocument.tsx similarity index 64% rename from web-client/src/ui/toolbar/CompileProject.tsx rename to web-client/src/ui/toolbar/ReloadDocument.tsx index b4eea08d..db8ceb87 100644 --- a/web-client/src/ui/toolbar/CompileProject.tsx +++ b/web-client/src/ui/toolbar/ReloadDocument.tsx @@ -11,9 +11,9 @@ import { viewSelector } from "core/store"; import { ToolbarControl } from "./util"; -export const CompileProject: ToolbarControl = { +export const ReloadDocument: ToolbarControl = { ToolbarButton: forwardRef((_, ref) => { - const { handler, disabled, icon, tooltip } = useCompileProjectControl(); + const { handler, disabled, icon, tooltip } = useReloadDocumentControl(); return ( { - const { handler, disabled, icon, tooltip } = useCompileProjectControl(); + const { handler, disabled, icon, tooltip } = useReloadDocumentControl(); return ( @@ -37,12 +37,12 @@ export const CompileProject: ToolbarControl = { }, }; -const useCompileProjectControl = () => { +function useReloadDocumentControl() { const kernel = useKernel(); - const { rootPath, compileInProgress, compilerReady } = + const { stageMode, rootPath, compileInProgress, compilerReady } = useSelector(viewSelector); const handler = useCallback(() => { - kernel.compile(); + kernel.reloadDocument(); }, [kernel]); const styles = useCommonStyles(); @@ -52,17 +52,32 @@ const useCompileProjectControl = () => { className={compileInProgress ? styles.spinningInfinite : ""} /> ); - const tooltip = getTooltip(!!rootPath, compileInProgress); + let tooltip; + let disabled; + if (stageMode === "edit") { + tooltip = getEditorTooltip(!!rootPath, compileInProgress); + disabled = !rootPath || compileInProgress || !compilerReady; + } else { + tooltip = getViewerTooltip(compileInProgress); + disabled = compileInProgress; + } return { handler, - disabled: !rootPath || compileInProgress || !compilerReady, + disabled, icon, tooltip, }; -}; +} -const getTooltip = (isOpened: boolean, compileInProgress: boolean) => { +function getViewerTooltip(compileInProgress: boolean) { + if (compileInProgress) { + return "Loading..."; + } + return "Reload Document"; +} + +function getEditorTooltip(isOpened: boolean, compileInProgress: boolean) { if (isOpened) { if (compileInProgress) { return "Compiling..."; @@ -70,4 +85,4 @@ const getTooltip = (isOpened: boolean, compileInProgress: boolean) => { return "Click to compile the project"; } return "Compile project"; -}; +} diff --git a/web-client/src/ui/toolbar/getHeaderControls.ts b/web-client/src/ui/toolbar/getHeaderControls.ts index d670414e..83ad5f97 100644 --- a/web-client/src/ui/toolbar/getHeaderControls.ts +++ b/web-client/src/ui/toolbar/getHeaderControls.ts @@ -12,7 +12,7 @@ import { SelectSection } from "./SelectSection"; import { OpenCloseProject } from "./OpenCloseProject"; import { SyncProject } from "./SyncProject"; import { SaveProject } from "./SaveProject"; -import { CompileProject } from "./CompileProject"; +import { ReloadDocument } from "./ReloadDocument"; import { OpenDocs } from "./OpenDocs"; import { Export } from "./Export"; @@ -33,14 +33,19 @@ export const getHeaderControls = ( // Doc Controls { priority: 40, - controls: [SelectSection, ViewDiagnostics, Export], + controls: [ + ...(mode === "view" ? [ReloadDocument] : []), + SelectSection, + ViewDiagnostics, + Export, + ], }, // Map Controls { priority: 20, controls: [SwitchMapLayer, ZoomIn, ZoomOut], }, - // Eitor + // Editor ...(mode !== "edit" ? [] : [ @@ -59,7 +64,7 @@ export const getHeaderControls = ( const getEditorControls = (editorMode: EditorMode): ToolbarControl[] => { if (editorMode === "web") { - return [CompileProject, SaveProject, SyncProject, OpenCloseProject]; + return [ReloadDocument, SaveProject, SyncProject, OpenCloseProject]; } - return [CompileProject, OpenCloseProject]; + return [ReloadDocument, OpenCloseProject]; }; diff --git a/web-client/tools/post-build.cjs b/web-client/tools/post-build.cjs index 7fe8c1e7..e255e62e 100644 --- a/web-client/tools/post-build.cjs +++ b/web-client/tools/post-build.cjs @@ -39,7 +39,7 @@ const viewerHtml = processHtml(indexHtml, VIEWER_TAG) return !l.includes("monaco"); }) .join(""); -const editorHtml = processHtml(indexHtml, EDITOR_TAG).join(""); +const editorHtml = processHtml(indexHtml, EDITOR_TAG).join("\n"); fs.writeFileSync(viewerHtmlPath, viewerHtml); fs.writeFileSync(editorHtmlPath, editorHtml);