diff --git a/compiler-core/Cargo.toml b/compiler-core/Cargo.toml index 52e88dc7..ee0d644d 100644 --- a/compiler-core/Cargo.toml +++ b/compiler-core/Cargo.toml @@ -32,7 +32,7 @@ log = "0.4.20" default = [ "tokio", "tokio-stream", - "cached/async_tokio_rt_multi_thread" + "cached/async_tokio_rt_multi_thread", ] wasm = [ "cached/wasm", diff --git a/compiler-core/src/pack/mod.rs b/compiler-core/src/pack/mod.rs index ebdc1eb5..b593f978 100644 --- a/compiler-core/src/pack/mod.rs +++ b/compiler-core/src/pack/mod.rs @@ -47,6 +47,9 @@ pub enum PackerError { #[error("Invalid path: {0}")] InvalidPath(String), + #[error("Invalid url: {0}")] + InvalidUrl(String), + #[error("Max depth of {0} levels of `use` is reached. Please make sure there are no circular dependencies.")] MaxUseDepthExceeded(usize), diff --git a/compiler-core/src/pack/resource/loader_cache.rs b/compiler-core/src/pack/resource/loader_cache.rs index ac54a300..f5595147 100644 --- a/compiler-core/src/pack/resource/loader_cache.rs +++ b/compiler-core/src/pack/resource/loader_cache.rs @@ -20,22 +20,45 @@ impl GlobalCacheLoader { #[cfg_attr(feature = "wasm", async_trait::async_trait(?Send))] impl ResourceLoader for GlobalCacheLoader { async fn load_raw(&self, r: &str) -> PackerResult> { - self.delegate.load_raw(r).await + load_raw_internal(&self.delegate, r).await } async fn load_image_url(&self, path: &str) -> PackerResult { - self.delegate.load_image_url(path).await + load_image_url_internal(&self.delegate, path).await } async fn load_structured(&self, path: &str) -> PackerResult { - // associative function not supported by cached crate - // so we need to use a helper load_structured_internal(&self.delegate, path).await } } +// associative function not supported by cached crate +// so we need to use helpers + +#[cached( + size=256, + key="String", + convert = r#"{ path.to_string() }"#, + // TTL of 5 minutes + time=300, +)] +async fn load_raw_internal(loader: &ArcLoader, path: &str) -> PackerResult> { + loader.load_raw(path).await +} + +#[cached( + size=256, + key="String", + convert = r#"{ path.to_string() }"#, + // TTL of 5 minutes + time=300, +)] +async fn load_image_url_internal(loader: &ArcLoader, path: &str) -> PackerResult { + loader.load_image_url(path).await +} + #[cached( - size=512, + size=256, key="String", convert = r#"{ path.to_string() }"#, // TTL of 5 minutes diff --git a/compiler-core/src/pack/resource/loader_url.rs b/compiler-core/src/pack/resource/loader_url.rs deleted file mode 100644 index 413112a1..00000000 --- a/compiler-core/src/pack/resource/loader_url.rs +++ /dev/null @@ -1,20 +0,0 @@ -use crate::pack::{PackerError, PackerResult}; - -use super::ResourceLoader; - -pub struct UrlLoader; - -#[cfg_attr(not(feature = "wasm"), async_trait::async_trait)] -#[cfg_attr(feature = "wasm", async_trait::async_trait(?Send))] -impl ResourceLoader for UrlLoader { - async fn load_raw(&self, _: &str) -> PackerResult> { - Err(PackerError::NotImpl( - "UrlLoader::load_raw is not implemented".to_string(), - )) - } - - async fn load_image_url(&self, url: &str) -> PackerResult { - // image is already a URL, so just return it - Ok(url.to_string()) - } -} diff --git a/compiler-core/src/pack/resource/mod.rs b/compiler-core/src/pack/resource/mod.rs index 3f4934d9..f45a129d 100644 --- a/compiler-core/src/pack/resource/mod.rs +++ b/compiler-core/src/pack/resource/mod.rs @@ -4,8 +4,6 @@ mod loader_cache; pub use loader_cache::*; mod loader_empty; pub use loader_empty::*; -mod loader_url; -pub use loader_url::*; mod resource_github; pub use resource_github::*; mod resource_impl; diff --git a/compiler-core/src/pack/resource/resource_github.rs b/compiler-core/src/pack/resource/resource_github.rs index b967b1b9..16660d1e 100644 --- a/compiler-core/src/pack/resource/resource_github.rs +++ b/compiler-core/src/pack/resource/resource_github.rs @@ -2,7 +2,6 @@ use std::sync::Arc; use crate::util::Path; -use cached::proc_macro::cached; use super::{ArcLoader, EmptyLoader, Resource, ResourcePath, ResourceResolver}; use crate::pack::{PackerError, PackerResult, ValidUse}; @@ -128,28 +127,7 @@ async fn get_github_url( reference: Option<&str>, ) -> PackerResult { let path = path.as_ref(); - let url = match reference { - Some(reference) => { - format!("https://raw.githubusercontent.com/{owner}/{repo}/{reference}/{path}") - } - None => { - let default_branch = get_default_branch(owner, repo).await?; - format!("https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{path}") - } - }; + let branch = reference.unwrap_or("main"); + let url = format!("https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}"); Ok(url) } - -/// Get the default branch of a repository. -#[cached( - key="String", - convert = r#"{ format!("{}/{}", _owner, _repo) }"#, - // 1 hour TTL - time=3600, -)] -async fn get_default_branch(_owner: &str, _repo: &str) -> PackerResult { - // TODO #31 - Err(PackerError::NotImpl( - "getting default branch not implemented".to_string(), - )) -} diff --git a/compiler-core/src/util/async_macro_wasm.rs b/compiler-core/src/util/async_macro_wasm.rs index 09195f35..51e04460 100644 --- a/compiler-core/src/util/async_macro_wasm.rs +++ b/compiler-core/src/util/async_macro_wasm.rs @@ -13,6 +13,11 @@ thread_local! { static CANCELLED: UnsafeCell = UnsafeCell::new(false); } +const BUDGET_MAX: u8 = 64; +thread_local! { + static BUDGET: UnsafeCell = UnsafeCell::new(BUDGET_MAX); +} + /// A signal for cancellation #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum WasmError { @@ -30,6 +35,19 @@ pub fn set_cancelled(value: bool) { /// /// Shared code for server and WASM should use the [`yield_now`] macro instead of calling this directly. pub async fn set_timeout_yield() -> Result<(), WasmError> { + let has_budget = BUDGET.with(|budget| unsafe { + let b_ref = budget.get(); + if *b_ref == 0 { + *b_ref = BUDGET_MAX; + false + } else { + *b_ref -= 1; + true + } + }); + if has_budget { + return Ok(()); + } let promise = WINDOW.with(|window| { Promise::new(&mut |resolve, _| { let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 0); diff --git a/compiler-wasm/src/api.rs b/compiler-wasm/src/api.rs index 6fec77e0..3869ff06 100644 --- a/compiler-wasm/src/api.rs +++ b/compiler-wasm/src/api.rs @@ -2,9 +2,7 @@ use std::cell::RefCell; use std::sync::Arc; use celerc::api::{CompilerMetadata, CompilerOutput, Setting}; -use celerc::pack::{ - ArcLoader, GlobalCacheLoader, LocalResourceResolver, Resource, ResourcePath, UrlLoader, -}; +use celerc::pack::{LocalResourceResolver, Resource, ResourcePath}; use celerc::util::Path; use celerctypes::ExecDoc; use js_sys::Function; @@ -12,8 +10,9 @@ use log::{info, LevelFilter}; use wasm_bindgen::JsValue; use web_sys::console; +use crate::loader_file::FileLoader; +use crate::loader_url::UrlLoader; use crate::logger::{self, Logger}; -use crate::resource::FileLoader; const LOGGER: Logger = Logger; @@ -24,7 +23,7 @@ thread_local! { thread_local! { #[allow(clippy::arc_with_non_send_sync)] - static URL_LOADER: ArcLoader = Arc::new(GlobalCacheLoader::new(Arc::new(UrlLoader))); + static URL_LOADER: Arc = Arc::new(UrlLoader::new()); } thread_local! { @@ -32,7 +31,7 @@ thread_local! { } /// Initialize -pub fn init(logger: JsValue, load_file: Function) { +pub fn init(logger: JsValue, load_file: Function, fetch: Function) { if let Err(e) = logger::bind_logger(logger) { console::error_1(&e); } @@ -51,6 +50,9 @@ pub fn init(logger: JsValue, load_file: Function) { FILE_LOADER.with(|loader| { loader.init(load_file); }); + URL_LOADER.with(|loader| { + loader.init(fetch); + }); info!("compiler initialized"); } diff --git a/compiler-wasm/src/build.rs b/compiler-wasm/src/build.rs index 43c8b4de..e0ffee42 100644 --- a/compiler-wasm/src/build.rs +++ b/compiler-wasm/src/build.rs @@ -62,6 +62,7 @@ fn build_wasm_pack_command() -> Command { fn override_typescript_definitions() -> io::Result<()> { println!("generating typescript definitions"); let mut d_ts = celercwasm::generate_d_ts_imports(); + d_ts.push_str(include_str!("./wasm.ts")); d_ts.push_str(&celercwasm::generate_d_ts()); fs::write(Path::new(OUTPUT_DIR).join("celercwasm.d.ts"), d_ts)?; Ok(()) diff --git a/compiler-wasm/src/lib.rs b/compiler-wasm/src/lib.rs index 20b7e6f9..c93671fa 100644 --- a/compiler-wasm/src/lib.rs +++ b/compiler-wasm/src/lib.rs @@ -7,8 +7,9 @@ mod api; mod wasm; use wasm::*; +mod loader_file; +mod loader_url; mod logger; -mod resource; // WASM output types import! { @@ -21,12 +22,14 @@ into! {ExecDoc} ffi!( /// Initialize - pub async fn initCompiler(logger: JsValue, load_file: Function) -> void { - api::init(logger, load_file); + pub async fn initCompiler(logger: JsValue, load_file: Function, fetch: Function) -> void { + api::init(logger, load_file, fetch); JsValue::UNDEFINED } /// Compile a document from web editor + /// + /// If use_cache is true, the compiler will use cached results loaded from URLs pub async fn compileDocument() -> Option { api::compile_document().await } diff --git a/compiler-wasm/src/resource.rs b/compiler-wasm/src/loader_file.rs similarity index 100% rename from compiler-wasm/src/resource.rs rename to compiler-wasm/src/loader_file.rs diff --git a/compiler-wasm/src/loader_url.rs b/compiler-wasm/src/loader_url.rs new file mode 100644 index 00000000..f229594b --- /dev/null +++ b/compiler-wasm/src/loader_url.rs @@ -0,0 +1,53 @@ +use std::cell::RefCell; + +use celerc::pack::{PackerError, PackerResult, ResourceLoader}; +use celerc::yield_now; +use js_sys::{Function, Uint8Array}; +use wasm_bindgen::{JsCast, JsValue}; + +use crate::wasm::{into_future, stub_function}; + +/// Loader for loading a URL using a provided JS function +pub struct UrlLoader { + /// Callback function to ask JS to load a file + /// + /// Takes in a string (url) as argument. + /// Returns a promise that resolves to a Uint8Array that could throw + fetch: RefCell, +} + +impl UrlLoader { + pub fn new() -> Self { + Self { + fetch: RefCell::new(stub_function()), + } + } + pub fn init(&self, fetch: Function) { + self.fetch.replace(fetch); + } +} + +#[async_trait::async_trait(?Send)] +impl ResourceLoader for UrlLoader { + async fn load_raw(&self, url: &str) -> PackerResult> { + yield_now!()?; + let result: Result = async { + let promise = self + .fetch + .borrow() + .call1(&JsValue::UNDEFINED, &JsValue::from(url))?; + let vec: Uint8Array = into_future(promise).await?.dyn_into()?; + Ok(vec) + } + .await; + // see if JS call is successful + let uint8arr = + result.map_err(|_| PackerError::LoadUrl(format!("loading URL failed: {url}")))?; + Ok(uint8arr.to_vec()) + } + + async fn load_image_url(&self, url: &str) -> PackerResult { + // image is already a URL, so just return it + Ok(url.to_string()) + } +} diff --git a/compiler-wasm/src/wasm.rs b/compiler-wasm/src/wasm.rs index c222fb2b..16b399b0 100644 --- a/compiler-wasm/src/wasm.rs +++ b/compiler-wasm/src/wasm.rs @@ -1,6 +1,6 @@ //! Utils for gluing WASM and JS -use js_sys::{Function, Promise}; +use js_sys::{Boolean, Function, Promise}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; @@ -148,6 +148,13 @@ macro_rules! into { } pub(crate) use into; +impl WasmFrom for bool { + fn from_wasm(value: JsValue) -> Result { + let b: Boolean = value.dyn_into()?; + Ok(b.into()) + } +} + /// Take a promise and return a future pub async fn into_future(promise: JsValue) -> Result { let promise: Promise = promise.dyn_into()?; diff --git a/compiler-wasm/src/wasm.ts b/compiler-wasm/src/wasm.ts new file mode 100644 index 00000000..2c75055a --- /dev/null +++ b/compiler-wasm/src/wasm.ts @@ -0,0 +1,2 @@ +type bool = boolean; +type JsValue = any; diff --git a/docs/src/route/file-structure.md b/docs/src/route/file-structure.md index f0d60614..3f3d1941 100644 --- a/docs/src/route/file-structure.md +++ b/docs/src/route/file-structure.md @@ -43,7 +43,8 @@ If the path does not start with `.`, `..`, or `/`, it will be considered a GitHu ``` {owner}/{repo}/{path/to/file}:{ref} ``` -The `:{ref}` can be a branch, tag, or commit hash. It is optional and when omitted, it will resolve to the default branch of that repo. +The `:{ref}` can be a branch, tag, or commit hash. +It is optional and when omitted, it will resolve to the `main` branch. Examples: ```yaml diff --git a/presets/botw/README.md b/presets/botw/README.md new file mode 100644 index 00000000..1c135971 --- /dev/null +++ b/presets/botw/README.md @@ -0,0 +1,2 @@ +# Presets for Breath of the Wild +README under construction. Tracked by https://github.com/Pistonite/celer/issues/27 diff --git a/presets/botw-map.yaml b/presets/botw/koroks.yaml similarity index 100% rename from presets/botw-map.yaml rename to presets/botw/koroks.yaml diff --git a/presets/botw/map.yaml b/presets/botw/map.yaml new file mode 100644 index 00000000..b5943392 --- /dev/null +++ b/presets/botw/map.yaml @@ -0,0 +1,20 @@ +map: + layers: + - name: Overworld + template-url: https://objmap.zeldamods.org/game_files/maptex/{z}/{x}/{y}.png + size: [24000, 20000] + max-native-zoom: 7 + zoom-bounds: [2, 9] + transform: + translate: [12000, 10000] + scale: [2, 2] + start-z: -5000 + attribution: + link: https://objmap.zeldamods.org + coord-map: + "2d": [x, y] + "3d": [x, z, y] + initial-coord: [-1099.10, 242.00, 1876.31] + initial-zoom: 3 + initial-color: "#38F" + diff --git a/presets/botw/presets-compat.yaml b/presets/botw/presets-compat.yaml new file mode 100644 index 00000000..e69de29b diff --git a/presets/botw/presets-ext.yaml b/presets/botw/presets-ext.yaml new file mode 100644 index 00000000..e69de29b diff --git a/presets/botw/presets.yaml b/presets/botw/presets.yaml new file mode 100644 index 00000000..e69de29b diff --git a/web-client/src/core/kernel/editor/CompMgr.ts b/web-client/src/core/kernel/editor/CompMgr.ts index 76c69e7e..2fa206bc 100644 --- a/web-client/src/core/kernel/editor/CompMgr.ts +++ b/web-client/src/core/kernel/editor/CompMgr.ts @@ -25,8 +25,14 @@ export class CompMgr { this.compiling = false; } - public async init(loadFile: RequestFileFunction) { - initCompiler(CompilerLog, loadFile); + public async init( + loadFile: RequestFileFunction, + loadUrl: RequestFileFunction, + ) { + initCompiler(CompilerLog, loadFile, (url: string) => { + CompilerLog.info(`loading ${url}`); + return loadUrl(url); + }); } /// Trigger compilation of the document @@ -80,7 +86,5 @@ export class CompMgr { window.clearTimeout(handle); this.dispatcher.dispatch(viewActions.setCompileInProgress(false)); this.compiling = false; - //wasm api should be something like: - //compile(requestfunction) -> Promise } } diff --git a/web-client/src/core/kernel/editor/EditorKernelImpl.ts b/web-client/src/core/kernel/editor/EditorKernelImpl.ts index ff83b546..256625a0 100644 --- a/web-client/src/core/kernel/editor/EditorKernelImpl.ts +++ b/web-client/src/core/kernel/editor/EditorKernelImpl.ts @@ -11,6 +11,7 @@ import { import { FileSys, FsResult } from "low/fs"; import { isInDarkMode } from "low/utils"; +import { fetchAsBytes } from "low/fetch"; import { EditorKernel } from "./EditorKernel"; import { EditorLog, toFsPath } from "./utils"; import { IdleMgr } from "./IdleMgr"; @@ -81,7 +82,10 @@ export class EditorKernelImpl implements EditorKernel { } public async init(): Promise { - await this.compMgr.init(this.fileMgr.getFileAsBytes.bind(this.fileMgr)); + await this.compMgr.init( + this.fileMgr.getFileAsBytes.bind(this.fileMgr), + fetchAsBytes, + ); } /// Reset the editor with a new file system. Unsaved changes will be lost diff --git a/web-client/src/core/kernel/editor/FileMgr.ts b/web-client/src/core/kernel/editor/FileMgr.ts index b4de876d..c4ecfe0e 100644 --- a/web-client/src/core/kernel/editor/FileMgr.ts +++ b/web-client/src/core/kernel/editor/FileMgr.ts @@ -2,7 +2,7 @@ import * as monaco from "monaco-editor"; import { AppDispatcher, viewActions } from "core/store"; import { FileSys, FsFile, FsPath, FsResult, FsResultCodes } from "low/fs"; -import { allocErr, allocOk, sleep } from "low/utils"; +import { allocErr, allocOk, createYielder, sleep } from "low/utils"; import { EditorContainerId, @@ -146,6 +146,7 @@ export class FileMgr { // so the current file is marked dirty await this.syncEditorToCurrentFile(); let success = true; + const _yield = createYielder(64); for (const id in this.files) { const fsFile = this.files[id]; const result = await this.loadChangesFromFsForFsFile( @@ -155,8 +156,7 @@ export class FileMgr { if (result.isErr()) { success = false; } - // yield to UI thread - await sleep(0); + await _yield(); } return success; }, @@ -259,6 +259,7 @@ export class FileMgr { // so the current file is marked dirty await this.syncEditorToCurrentFile(); let success = true; + const _yield = createYielder(64); for (const id in this.files) { const fsFile = this.files[id]; const result = await this.saveChangesToFsForFsFile( @@ -268,8 +269,7 @@ export class FileMgr { if (result.isErr()) { success = false; } - // yield to UI thread - await sleep(0); + await _yield(); } return success; }, @@ -412,35 +412,6 @@ export class FileMgr { }); } - // public async needsRecompile(): Promise { - // return await this.ensureLockedFs("needsRecompile", async () => { - // if (!this.fs) { - // return false; - // } - // for (const id in this.files) { - // const fsFile = this.files[id]; - // if (fsFile.wasChangedSinceLastCompile()) { - // return true; - // } - // await sleep(0); - // } - // return false; - // }); - // } - // - // public wasFileChangedSinceLastCompile(path: string): boolean { - // if (!this.fs) { - // return false; - // } - // let fsFile = this.files[path]; - // if (!fsFile) { - // const fsPath = toFsPath(path.split("/")); - // fsFile = new FsFile(this.fs, fsPath); - // this.files[fsPath.path] = fsFile; - // } - // return fsFile.wasChangedSinceLastCompile(); - // } - private async attachEditor() { let div = document.getElementById(EditorContainerId); while (!div) { diff --git a/web-client/src/low/fetch.ts b/web-client/src/low/fetch.ts new file mode 100644 index 00000000..4570cce2 --- /dev/null +++ b/web-client/src/low/fetch.ts @@ -0,0 +1,25 @@ +import { sleep } from "./utils"; + +export const fetchAsBytes = async (url: string): Promise => { + const RETRY_COUNT = 3; + let error: unknown; + for (let i = 0; i < RETRY_COUNT; i++) { + try { + const response = await fetch(url, { + cache: "reload", + }); + if (response.ok) { + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + } catch (e) { + console.error(e); + error = e; + await sleep(50); + } + } + if (error) { + throw error; + } + throw new Error("unknown error"); +}; diff --git a/web-client/src/low/utils/index.ts b/web-client/src/low/utils/index.ts index 0427fca0..221b3326 100644 --- a/web-client/src/low/utils/index.ts +++ b/web-client/src/low/utils/index.ts @@ -7,6 +7,7 @@ export * from "./Debouncer"; export * from "./Pool"; export * from "./FileSaver"; export * from "./Result"; +export * from "./yielder"; export const isInDarkMode = () => !!( diff --git a/web-client/src/low/utils/yielder.ts b/web-client/src/low/utils/yielder.ts new file mode 100644 index 00000000..04198dc5 --- /dev/null +++ b/web-client/src/low/utils/yielder.ts @@ -0,0 +1,14 @@ +/// Create a yielder that can be used to yield to UI thread when the budget runs out +export const createYielder = (budget: number) => { + let currentBudget = budget; + return () => { + if (currentBudget <= 0) { + currentBudget = budget; + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + currentBudget--; + return Promise.resolve(); + }; +}; diff --git a/web-client/src/ui/doc/Doc.css b/web-client/src/ui/doc/Doc.css index 5d1a6a56..031ddf2d 100644 --- a/web-client/src/ui/doc/Doc.css +++ b/web-client/src/ui/doc/Doc.css @@ -82,6 +82,25 @@ display: flex; } +.docline-primary-text span { + word-wrap: break-word; +} +.docline-primary-text { + width: 308px; +} +.docline-primary-text.docline-icon-text { + width: 258px; +} +.docline-secondary-text span { + word-wrap: break-word; +} +.docline-secondary-text { + width: 308px; +} +.docline-secondary-text.docline-icon-text { + width: 258px; +} + .docline-icon-container { min-width: 50px; height: 50px; @@ -136,4 +155,5 @@ } .docline-diagnostic-body { padding: 4px 0 4px 4px; + word-break: break-word; } diff --git a/web-client/src/ui/doc/DocLine.tsx b/web-client/src/ui/doc/DocLine.tsx index 0f931dce..73158d2e 100644 --- a/web-client/src/ui/doc/DocLine.tsx +++ b/web-client/src/ui/doc/DocLine.tsx @@ -92,7 +92,12 @@ export const DocLine: React.FC = ({ )}
-
+
{removeTags(text).trim().length === 0 ? (   ) : ( @@ -100,7 +105,12 @@ export const DocLine: React.FC = ({ )}
{secondaryText.length > 0 && ( -
+
)} diff --git a/web-client/src/ui/doc/Poor.tsx b/web-client/src/ui/doc/Poor.tsx index fb27bfe9..7c811e5a 100644 --- a/web-client/src/ui/doc/Poor.tsx +++ b/web-client/src/ui/doc/Poor.tsx @@ -32,7 +32,13 @@ type PoorBlockProps = DocPoorText & { const PoorBlock: React.FC = ({ type, data, textProps }) => { return ( - {type === "link" ? {data} : data} + {type === "link" ? ( + + {data} + + ) : ( + data + )} ); }; diff --git a/web-client/src/ui/doc/Rich.tsx b/web-client/src/ui/doc/Rich.tsx index b6410b94..fe30a232 100644 --- a/web-client/src/ui/doc/Rich.tsx +++ b/web-client/src/ui/doc/Rich.tsx @@ -51,7 +51,13 @@ const RichBlock: React.FC = ({ text, tag, link, size }) => { backgroundColor: tag.background || undefined, }} > - {link ? {text} : text} + {link ? ( + + {text} + + ) : ( + text + )} ); }; diff --git a/web-themes/src/default.css b/web-themes/src/default.css index 4978ec44..48747624 100644 --- a/web-themes/src/default.css +++ b/web-themes/src/default.css @@ -74,6 +74,9 @@ .docline-diagnostic-body.docline-diagnostic-error { background-color: #ff6666; } +.docline-diagnostic-body.docline-diagnostic-error a { + color: #0000ff; +} /* Warning */ .docline-diagnostic-head.docline-diagnostic-warn { background-color: #883300; diff --git a/web-themes/src/granatus.css b/web-themes/src/granatus.css index c27f9a3b..a2f68de5 100644 --- a/web-themes/src/granatus.css +++ b/web-themes/src/granatus.css @@ -64,6 +64,9 @@ .docline-diagnostic-body.docline-diagnostic-error { background-color: #ff6666; } +.docline-diagnostic-body.docline-diagnostic-error a { + color: #0000ff; +} /* Warning */ .docline-diagnostic-head.docline-diagnostic-warn { background-color: #883300;