From 30d6d138f71624b7accaade8f7ba82c86703cc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 17:33:48 -0600 Subject: [PATCH 01/10] feat(config): evaluate lithos.luau and lithos.lua via lune Lithos can now load Luau and Lua project configs through the Lune runtime. Discovery checks lithos.yml, lithos.yaml, lithos.json, lithos.luau, lithos.lua, then legacy mantle.* files; explicit .luau and .lua paths are honored. Evaluation runs in a Lune subprocess via a self-cleaning wrapper script, decodes the returned table from JSON, and surfaces runtime, shape, and schema errors with the offending config path. --- Cargo.lock | 1 + src/rbx_lithos/Cargo.toml | 1 + src/rbx_lithos/src/config.rs | 13 +- src/rbx_lithos/src/config/loading.rs | 60 ++- src/rbx_lithos/src/config/luau.rs | 488 ++++++++++++++++++++ src/rbx_lithos/src/config/luau_wrapper.luau | 52 +++ 6 files changed, 606 insertions(+), 9 deletions(-) create mode 100644 src/rbx_lithos/src/config/luau.rs create mode 100644 src/rbx_lithos/src/config/luau_wrapper.luau diff --git a/Cargo.lock b/Cargo.lock index 4974334..afd868e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3019,6 +3019,7 @@ dependencies = [ "rusoto_s3", "schemars", "serde", + "serde_json", "serde_yaml", "sha2", "tempfile", diff --git a/src/rbx_lithos/Cargo.toml b/src/rbx_lithos/Cargo.toml index e51dbc1..6e618f9 100644 --- a/src/rbx_lithos/Cargo.toml +++ b/src/rbx_lithos/Cargo.toml @@ -15,6 +15,7 @@ logger = { path = "../logger" } dotenv = "0.15.0" serde_yaml = { version = "0.8" } +serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } clap = "2.33.0" glob = "0.3.0" diff --git a/src/rbx_lithos/src/config.rs b/src/rbx_lithos/src/config.rs index 19106a8..e665e3a 100644 --- a/src/rbx_lithos/src/config.rs +++ b/src/rbx_lithos/src/config.rs @@ -1,12 +1,14 @@ //! Project configuration types. //! //! This module defines the data shape of the user-facing `lithos.yml` and -//! `lithos.yaml`, and `lithos.json` files (plus the legacy `mantle.yml` / -//! `mantle.yaml` aliases). Two focused -//! submodules handle -//! adjacent concerns: +//! `lithos.yaml`, `lithos.json`, `lithos.luau`, and `lithos.lua` files (plus +//! the legacy `mantle.yml` / `mantle.yaml` aliases). Three focused +//! submodules handle adjacent concerns: //! -//! - [`loading`] – reads and parses the YAML/JSON file from disk. +//! - [`loading`] – reads and parses config files from disk, dispatching by +//! extension between the static (YAML / JSON) and executable (Luau / Lua) +//! formats. +//! - [`luau`] – evaluates `.luau` and `.lua` configs via the Lune runtime. //! - [`mapping`] – pure `From` impls converting these config types into the //! request models used by `rbx_api`. @@ -19,6 +21,7 @@ use serde::{Deserialize, Serialize}; use url::Url; mod loading; +mod luau; mod mapping; pub use loading::load_project_config; diff --git a/src/rbx_lithos/src/config/loading.rs b/src/rbx_lithos/src/config/loading.rs index 16f6f66..655c694 100644 --- a/src/rbx_lithos/src/config/loading.rs +++ b/src/rbx_lithos/src/config/loading.rs @@ -12,9 +12,15 @@ use dotenv::from_path; use log::info; use yansi::Paint; -use super::Config; - -const PRIMARY_CONFIG_FILENAMES: &[&str] = &["lithos.yml", "lithos.yaml", "lithos.json"]; +use super::{luau, Config}; + +const PRIMARY_CONFIG_FILENAMES: &[&str] = &[ + "lithos.yml", + "lithos.yaml", + "lithos.json", + "lithos.luau", + "lithos.lua", +]; const LEGACY_CONFIG_FILENAMES: &[&str] = &["mantle.yml", "mantle.yaml"]; fn config_candidates(project_path: &Path) -> Vec { @@ -94,6 +100,10 @@ fn parse_project_path(project: Option<&str>) -> Result<(PathBuf, PathBuf), Strin } fn load_config_file(config_file: &Path) -> Result { + if luau::is_lua_config_path(config_file) { + return luau::load_lua_config(config_file).map(|eval| eval.config); + } + let data = fs::read_to_string(config_file).map_err(|e| { format!( "Unable to read config file: {}\n\t{}", @@ -155,7 +165,9 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::{load_project_config, LEGACY_CONFIG_FILENAMES, PRIMARY_CONFIG_FILENAMES}; + use super::{ + load_project_config, parse_project_path, LEGACY_CONFIG_FILENAMES, PRIMARY_CONFIG_FILENAMES, + }; static NEXT_TEMP_DIR_ID: AtomicUsize = AtomicUsize::new(0); @@ -322,6 +334,46 @@ target: assert!(error.contains(LEGACY_CONFIG_FILENAMES[1])); } + #[test] + fn discovery_prefers_yaml_and_json_over_luau() { + // Selection should be a pure function of which files exist; verify + // precedence without actually evaluating any of them. + let project_dir = TempProjectDir::new(); + project_dir.write("lithos.json", JSON_CONFIG); + project_dir.write("lithos.luau", "return {}"); + project_dir.write("lithos.lua", "return {}"); + + let (_, config_path) = + parse_project_path(Some(project_dir.path().to_str().unwrap())).unwrap(); + + assert_eq!(config_path.file_name().unwrap(), "lithos.json"); + } + + #[test] + fn discovery_prefers_luau_over_lua_and_legacy_mantle() { + let project_dir = TempProjectDir::new(); + project_dir.write("lithos.luau", "return {}"); + project_dir.write("lithos.lua", "return {}"); + project_dir.write("mantle.yml", YML_CONFIG); + + let (_, config_path) = + parse_project_path(Some(project_dir.path().to_str().unwrap())).unwrap(); + + assert_eq!(config_path.file_name().unwrap(), "lithos.luau"); + } + + #[test] + fn missing_config_error_mentions_luau_in_search_path() { + let project_dir = TempProjectDir::new(); + + let error = load_project_config(Some(project_dir.path().to_str().unwrap())) + .err() + .unwrap(); + + assert!(error.contains("lithos.luau")); + assert!(error.contains("lithos.lua")); + } + #[test] fn load_project_config_loads_project_dotenv() { let env_key = "LITHOS_TEST_PROJECT_DOTENV_8740"; diff --git a/src/rbx_lithos/src/config/luau.rs b/src/rbx_lithos/src/config/luau.rs new file mode 100644 index 0000000..04071a6 --- /dev/null +++ b/src/rbx_lithos/src/config/luau.rs @@ -0,0 +1,488 @@ +//! Luau / Lua project config evaluation. +//! +//! Lithos delegates execution to [Lune](https://lune-org.github.io/docs), an +//! external Luau runtime aimed at Roblox tooling. We do not interpret Luau +//! ourselves: a small wrapper script is handed to `lune run`, which `require`s +//! the user's config file and prints the resulting table as JSON between +//! sentinel markers. Rust then parses the markers and decodes the JSON +//! payload into [`Config`]. +//! +//! This keeps the surface area small while letting users write idiomatic +//! Luau (helper functions, loops, environment-variable branching, ...) just +//! like any other Lune script. Documented capabilities and limits live in the +//! `docs/site/pages/docs/configuration` pages. +//! +//! Side-effectful boundary: spawns a subprocess, writes a temp file, reads +//! environment variables, and prints to the logger on failure. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use super::Config; + +/// Environment variable that overrides the `lune` binary used to evaluate +/// `.luau` / `.lua` configs. Useful for pinning a specific Lune build in CI +/// or pointing at a forked runtime. +pub const LUNE_BIN_ENV: &str = "LITHOS_LUNE"; + +const DEFAULT_LUNE_BIN: &str = "lune"; + +const CONFIG_BEGIN_MARKER: &str = "@@LITHOS_CONFIG_BEGIN@@"; +const CONFIG_END_MARKER: &str = "@@LITHOS_CONFIG_END@@"; + +/// Lune wrapper script. Receives the require-path to the user's config (no +/// extension, relative to this wrapper's location) as `process.args[1]` and +/// prints the resulting config table as JSON between sentinel markers. +/// +/// Errors are written to stderr and surfaced via the process exit code. +const WRAPPER_SCRIPT: &str = include_str!("luau_wrapper.luau"); + +/// Result of evaluating a Luau / Lua config file. +pub struct LuauEvaluation { + pub config: Config, +} + +/// Returns `true` for file paths Lithos should evaluate via Lune. +pub fn is_lua_config_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|s| s.to_str()), + Some("lua") | Some("luau") + ) +} + +/// Evaluate a Luau or Lua project config and decode its returned table as +/// a [`Config`]. +pub fn load_lua_config(config_file: &Path) -> Result { + let canonical_raw = config_file.canonicalize().map_err(|e| { + format!( + "Unable to resolve Luau config path {}: {}", + config_file.display(), + e + ) + })?; + // Windows `canonicalize()` returns paths with the `\\?\` verbatim + // prefix. Lune does not normalize that prefix when resolving relative + // `require()` calls from the running script, so we strip it before + // handing the wrapper path to Lune. + let canonical = strip_verbatim_prefix(&canonical_raw); + let user_dir = canonical.parent().ok_or_else(|| { + format!( + "Luau config path {} has no parent directory", + config_file.display() + ) + })?; + let user_stem = canonical + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + format!( + "Luau config path {} has no valid file stem", + config_file.display() + ) + })?; + + let lune_bin = env::var(LUNE_BIN_ENV).unwrap_or_else(|_| DEFAULT_LUNE_BIN.to_string()); + + // Place the wrapper inside the same directory as the user's config so the + // `require()` path is just `./`. Lune's require() resolves relative + // to the calling script and cross-directory traversal has proven brittle + // on Windows (canonical `\\?\` prefixes, separator normalization, etc.). + let wrapper = WrapperFile::new_in(user_dir)?; + let require_path = format!("./{}", user_stem); + + let output = Command::new(&lune_bin) + .arg("run") + .arg(wrapper.path()) + .arg(&require_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| { + format!( + "Failed to launch Lune ('{}') to evaluate {}: {}\n\ + Hint: install Lune from https://lune-org.github.io/docs and ensure it is on PATH,\n\ + or set the {} environment variable to point at a Lune binary.", + lune_bin, + config_file.display(), + e, + LUNE_BIN_ENV, + ) + })?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Err(format!( + "Lune failed to evaluate {} (exit code {}):\n{}", + config_file.display(), + output + .status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "".into()), + indent_block(stderr.trim_end()), + )); + } + + let json_payload = extract_marker_payload(&stdout, &stderr, config_file)?; + + let config: Config = serde_json::from_str(&json_payload).map_err(|e| { + format!( + "Luau config {} returned data that does not match the Lithos config schema:\n\t{}", + config_file.display(), + e + ) + })?; + + Ok(LuauEvaluation { config }) +} + +fn extract_marker_payload( + stdout: &str, + stderr: &str, + config_file: &Path, +) -> Result { + let start = stdout.find(CONFIG_BEGIN_MARKER).ok_or_else(|| { + format!( + "Lune did not emit a config payload for {}. Make sure your script returns a table or \ + `{{ config = }}`.\n\ + stdout:\n{}\n\ + stderr:\n{}", + config_file.display(), + indent_block(stdout.trim_end()), + indent_block(stderr.trim_end()), + ) + })?; + let after_begin = start + CONFIG_BEGIN_MARKER.len(); + let end_rel = stdout[after_begin..] + .find(CONFIG_END_MARKER) + .ok_or_else(|| { + format!( + "Lune emitted a truncated config payload for {} (missing end marker).", + config_file.display() + ) + })?; + Ok(stdout[after_begin..after_begin + end_rel] + .trim() + .to_string()) +} + +/// Strips the Windows verbatim `\\?\` prefix from a path if present. On +/// non-Windows platforms (and for paths without the prefix) the input is +/// returned unchanged. Lune chokes on verbatim-prefixed script paths when +/// resolving relative requires, so we hand it normal `C:\...` paths. +fn strip_verbatim_prefix(path: &Path) -> PathBuf { + let lossy = path.to_string_lossy(); + if let Some(stripped) = lossy.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path.to_path_buf() + } +} + +fn indent_block(text: &str) -> String { + if text.is_empty() { + return "\t".to_string(); + } + text.lines() + .map(|line| format!("\t{}", line)) + .collect::>() + .join("\n") +} + +/// Computes a `require()`-compatible path from `from_dir` to `target_file` +/// with the extension stripped. The result always starts with `./` or `../` +/// so Lune accepts it. Both paths must already be absolute. +/// +/// Kept available for tests and possible future cross-directory use; the +/// production loader places the wrapper inside the user's config directory +/// instead and uses the simpler `./` form. +#[cfg(test)] +fn relative_require_path(from_dir: &Path, target_file: &Path) -> String { + let stripped = target_file.with_extension(""); + let from = normalize_components(from_dir); + let to = normalize_components(&stripped); + + let mut common = 0; + while common < from.len() && common < to.len() && from[common] == to[common] { + common += 1; + } + + let ups = from.len() - common; + let mut parts: Vec = Vec::new(); + if ups == 0 { + parts.push(".".to_string()); + } else { + for _ in 0..ups { + parts.push("..".to_string()); + } + } + for c in &to[common..] { + parts.push(c.clone()); + } + parts.join("/") +} + +#[cfg(test)] +fn normalize_components(path: &Path) -> Vec { + use std::path::Component; + path.components() + .filter_map(|c| match c { + Component::Prefix(prefix) => Some(prefix.as_os_str().to_string_lossy().to_string()), + Component::RootDir => None, + Component::CurDir => None, + Component::ParentDir => Some("..".to_string()), + Component::Normal(s) => Some(s.to_string_lossy().to_string()), + }) + .collect() +} + +/// Self-cleaning temp file holding the Lune wrapper script. The wrapper is +/// written into `host_dir` with a unique hidden file name so the wrapper can +/// `require("./")` to reach the user's config. +struct WrapperFile { + path: PathBuf, +} + +impl WrapperFile { + fn new_in(host_dir: &Path) -> Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let name = format!( + ".lithos-luau-eval-{}-{}.luau", + std::process::id(), + timestamp + ); + let path = host_dir.join(name); + fs::write(&path, WRAPPER_SCRIPT).map_err(|e| { + format!( + "Unable to write Luau wrapper script to {}: {}", + path.display(), + e + ) + })?; + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for WrapperFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn relative_require_path_within_same_subtree() { + let from = PathBuf::from(if cfg!(windows) { + r"C:\tmp\wrap" + } else { + "/tmp/wrap" + }); + let target = PathBuf::from(if cfg!(windows) { + r"C:\projects\demo\lithos.luau" + } else { + "/projects/demo/lithos.luau" + }); + let rel = relative_require_path(&from, &target); + // Always starts with ./ or ../ + assert!(rel.starts_with("./") || rel.starts_with("../")); + assert!(rel.ends_with("/lithos")); + } + + #[test] + fn is_lua_config_path_detects_both_extensions() { + assert!(is_lua_config_path(Path::new("lithos.lua"))); + assert!(is_lua_config_path(Path::new("lithos.luau"))); + assert!(!is_lua_config_path(Path::new("lithos.yml"))); + assert!(!is_lua_config_path(Path::new("lithos.json"))); + } + + // ----- Lune-gated end-to-end tests ---------------------------------- + // + // These tests shell out to a real `lune` binary. They are skipped (and + // print a clear message) when Lune is not on PATH so contributors who + // have not installed Lune locally can still run `cargo test`. + + fn lune_available() -> bool { + let bin = std::env::var(LUNE_BIN_ENV).unwrap_or_else(|_| DEFAULT_LUNE_BIN.to_string()); + Command::new(bin) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + struct TempLuauDir(PathBuf); + + impl TempLuauDir { + fn new() -> Self { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let mut p = env::temp_dir(); + p.push(format!("lithos-luau-test-{}-{}", std::process::id(), nanos)); + fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn write(&self, name: &str, content: &str) -> PathBuf { + let f = self.0.join(name); + fs::write(&f, content).unwrap(); + f + } + } + + impl Drop for TempLuauDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + const VALID_LUAU: &str = r#" +local config = { + environments = { + { label = "production", branches = { "main" } }, + }, + target = { + experience = { + places = { + start = { file = "place.rbxl" }, + }, + }, + }, +} +return config +"#; + + #[test] + fn evaluates_valid_luau_config_to_typed_config() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write("lithos.luau", VALID_LUAU); + + let eval = match load_lua_config(&file) { + Ok(eval) => eval, + Err(err) => panic!("luau evaluation should succeed: {}", err), + }; + + assert_eq!(eval.config.environments.len(), 1); + assert_eq!(eval.config.environments[0].label, "production"); + } + + #[test] + fn accepts_table_with_explicit_config_field() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#" +return { + config = { + environments = { { label = "wrapped", branches = { "main" } } }, + target = { experience = { places = { start = { file = "p.rbxl" } } } }, + }, +} +"#, + ); + + let eval = match load_lua_config(&file) { + Ok(eval) => eval, + Err(err) => panic!("luau evaluation should succeed: {}", err), + }; + assert_eq!(eval.config.environments[0].label, "wrapped"); + } + + #[test] + fn surfaces_runtime_errors_with_config_path() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#"error("boom from user config") +"#, + ); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("runtime error should fail load"), + Err(e) => e, + }; + assert!( + err.contains(file.file_name().unwrap().to_string_lossy().as_ref()), + "error should mention the config file path; got: {}", + err + ); + assert!( + err.contains("boom from user config"), + "error should preserve the underlying message; got: {}", + err + ); + } + + #[test] + fn rejects_non_table_return_values() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write("lithos.luau", "return 42\n"); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("scalar return should fail"), + Err(e) => e, + }; + assert!(err.to_lowercase().contains("table"), "got: {}", err); + } + + #[test] + fn rejects_invalid_config_shape_with_schema_error() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + // `environments` and `target` are required. + let file = dir.write( + "lithos.luau", + "return { totallyUnknownTopLevelKey = true }\n", + ); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("schema mismatch should fail"), + Err(e) => e, + }; + assert!( + err.contains("schema") || err.contains("missing") || err.contains("unknown"), + "expected a schema-style error; got: {}", + err + ); + } +} diff --git a/src/rbx_lithos/src/config/luau_wrapper.luau b/src/rbx_lithos/src/config/luau_wrapper.luau new file mode 100644 index 0000000..264e7b7 --- /dev/null +++ b/src/rbx_lithos/src/config/luau_wrapper.luau @@ -0,0 +1,52 @@ +--!strict +-- Lithos Luau config wrapper. +-- +-- Lune invokes this script as `lune run wrapper.luau ` where +-- is a Lune-compatible relative path (no extension) to the +-- user's `lithos.luau` / `lithos.lua` file. +-- +-- Contract: +-- * The user script may return either: +-- - a config table directly, or +-- - a table shaped `{ config =
, ... }` (additional fields are +-- currently reserved for future hook support). +-- * Anything written to stdout outside the sentinel markers is ignored by +-- Lithos, but the user is still free to `print` for debugging. +-- +-- Important: Lune's `require` resolves relative paths using `debug.getinfo` +-- on the calling stack frame. Wrapping it in `pcall` hides the source frame, +-- which breaks path resolution. Errors from `require` / the user script are +-- therefore left to propagate; Lune prints them to stderr and exits non-zero, +-- and the Rust caller surfaces both. + +local serde = require("@lune/serde") +local stdio = require("@lune/stdio") +local process = require("@lune/process") + +local userPath = process.args[1] +if type(userPath) ~= "string" or userPath == "" then + stdio.ewrite("[lithos] internal error: missing user config path argument\n") + process.exit(64) +end + +local mod = require(userPath) + +local config +if type(mod) == "table" and type(mod.config) == "table" then + config = mod.config +elseif type(mod) == "table" then + config = mod +else + stdio.ewrite("[lithos] user config must return a table or `{ config =
}`, got " .. type(mod) .. "\n") + process.exit(66) +end + +local okEncode, encoded = pcall(serde.encode, "json", config) +if not okEncode then + stdio.ewrite("[lithos] failed to encode user config as JSON: " .. tostring(encoded) .. "\n") + process.exit(67) +end + +stdio.write("\n@@LITHOS_CONFIG_BEGIN@@\n") +stdio.write(encoded) +stdio.write("\n@@LITHOS_CONFIG_END@@\n") From b6ac410467dd01082d6e42450567a4aec3642981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 17:37:08 -0600 Subject: [PATCH 02/10] feat(config): execute user-defined hooks from luau configs Luau project tables may now expose an onConfigLoaded function alongside config, which the Lune wrapper invokes synchronously and whose return value (when a table) replaces the decoded config. Other on*-prefixed callbacks are reported back to Lithos as registered hooks so future deploy lifecycle steps can route into them. --- src/rbx_lithos/src/config/luau.rs | 94 ++++++++++++++++++++- src/rbx_lithos/src/config/luau_wrapper.luau | 66 +++++++++++++-- 2 files changed, 150 insertions(+), 10 deletions(-) diff --git a/src/rbx_lithos/src/config/luau.rs b/src/rbx_lithos/src/config/luau.rs index 04071a6..241c2f7 100644 --- a/src/rbx_lithos/src/config/luau.rs +++ b/src/rbx_lithos/src/config/luau.rs @@ -44,6 +44,12 @@ const WRAPPER_SCRIPT: &str = include_str!("luau_wrapper.luau"); /// Result of evaluating a Luau / Lua config file. pub struct LuauEvaluation { pub config: Config, + /// Names of hook functions the user defined at the top level of the + /// returned table (e.g. `onConfigLoaded`, `onBeforeDeploy`). Lithos uses + /// this list to log which hooks were registered and to know whether to + /// re-invoke Lune for lifecycle hooks in future deploy steps. + #[allow(dead_code)] // Reserved for upcoming deploy-lifecycle integration. + pub hooks: Vec, } /// Returns `true` for file paths Lithos should evaluate via Lune. @@ -132,7 +138,22 @@ pub fn load_lua_config(config_file: &Path) -> Result { let json_payload = extract_marker_payload(&stdout, &stderr, config_file)?; - let config: Config = serde_json::from_str(&json_payload).map_err(|e| { + #[derive(serde::Deserialize)] + struct Envelope { + config: serde_json::Value, + #[serde(default)] + hooks: Vec, + } + + let envelope: Envelope = serde_json::from_str(&json_payload).map_err(|e| { + format!( + "Luau config {} produced a payload Lithos could not decode:\n\t{}", + config_file.display(), + e + ) + })?; + + let config: Config = serde_json::from_value(envelope.config).map_err(|e| { format!( "Luau config {} returned data that does not match the Lithos config schema:\n\t{}", config_file.display(), @@ -140,7 +161,10 @@ pub fn load_lua_config(config_file: &Path) -> Result { ) })?; - Ok(LuauEvaluation { config }) + Ok(LuauEvaluation { + config, + hooks: envelope.hooks, + }) } fn extract_marker_payload( @@ -485,4 +509,70 @@ return { err ); } + + #[test] + fn on_config_loaded_hook_can_transform_config() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#" +return { + config = { + environments = { { label = "production", branches = { "main" } } }, + target = { experience = { places = { start = { file = "p.rbxl" } } } }, + }, + onConfigLoaded = function(config) + table.insert(config.environments, { label = "staging", branches = { "develop" } }) + return config + end, + onBeforeDeploy = function() end, +} +"#, + ); + + let eval = match load_lua_config(&file) { + Ok(eval) => eval, + Err(err) => panic!("hook evaluation should succeed: {}", err), + }; + assert_eq!(eval.config.environments.len(), 2); + assert_eq!(eval.config.environments[1].label, "staging"); + let mut hook_names = eval.hooks.clone(); + hook_names.sort(); + assert_eq!(hook_names, vec!["onBeforeDeploy", "onConfigLoaded"]); + } + + #[test] + fn on_config_loaded_hook_failure_is_surfaced() { + if !lune_available() { + eprintln!("skipping: lune not available on PATH"); + return; + } + let dir = TempLuauDir::new(); + let file = dir.write( + "lithos.luau", + r#" +return { + config = { + environments = { { label = "production", branches = { "main" } } }, + target = { experience = { places = { start = { file = "p.rbxl" } } } }, + }, + onConfigLoaded = function() error("hook exploded") end, +} +"#, + ); + + let err = match load_lua_config(&file) { + Ok(_) => panic!("hook failure should propagate"), + Err(e) => e, + }; + assert!( + err.contains("onConfigLoaded") && err.contains("hook exploded"), + "expected hook error to surface; got: {}", + err + ); + } } diff --git a/src/rbx_lithos/src/config/luau_wrapper.luau b/src/rbx_lithos/src/config/luau_wrapper.luau index 264e7b7..5a6503b 100644 --- a/src/rbx_lithos/src/config/luau_wrapper.luau +++ b/src/rbx_lithos/src/config/luau_wrapper.luau @@ -8,8 +8,12 @@ -- Contract: -- * The user script may return either: -- - a config table directly, or --- - a table shaped `{ config =
, ... }` (additional fields are --- currently reserved for future hook support). +-- - a table shaped `{ config =
, ... }` with optional hook +-- functions (e.g. `onConfigLoaded`) alongside `config`. +-- * Hook functions discovered at the top level are reported back to Lithos +-- and (for `onConfigLoaded`) invoked here. Function-valued keys are +-- stripped from the config table before JSON encoding so users can mix +-- data and hook callbacks in a single return value. -- * Anything written to stdout outside the sentinel markers is ignored by -- Lithos, but the user is still free to `print` for debugging. -- @@ -31,17 +35,63 @@ end local mod = require(userPath) +if type(mod) ~= "table" then + stdio.ewrite("[lithos] user config must return a table or `{ config =
, ... }`, got " .. type(mod) .. "\n") + process.exit(66) +end + +-- Collect hook functions defined at the top level of the returned table. +-- Hooks must use the `on` naming convention so we can distinguish +-- them from user helpers that happen to be defined at the top level. +local hooks = {} +for k, v in pairs(mod) do + if type(v) == "function" and type(k) == "string" and k:sub(1, 2) == "on" then + table.insert(hooks, k) + end +end + +-- Resolve the config table. local config -if type(mod) == "table" and type(mod.config) == "table" then +if type(mod.config) == "table" then config = mod.config -elseif type(mod) == "table" then - config = mod else - stdio.ewrite("[lithos] user config must return a table or `{ config =
}`, got " .. type(mod) .. "\n") - process.exit(66) + -- Treat the returned table itself as the config, stripping out any + -- function values so JSON encoding does not fail on hook callbacks. + config = {} + for k, v in pairs(mod) do + if type(v) ~= "function" then + config[k] = v + end + end +end + +-- Invoke the synchronous `onConfigLoaded` hook if present. It receives the +-- decoded config table and may return a replacement table. +if type(mod.onConfigLoaded) == "function" then + local okHook, hookResult = pcall(mod.onConfigLoaded, config) + if not okHook then + stdio.ewrite("[lithos] onConfigLoaded hook failed: " .. tostring(hookResult) .. "\n") + process.exit(68) + end + if type(hookResult) == "table" then + config = hookResult + elseif hookResult ~= nil then + stdio.ewrite("[lithos] onConfigLoaded must return a table or nil; got " .. type(hookResult) .. "\n") + process.exit(69) + end +end + +local envelope = { + config = config, +} +-- Lune's JSON encoder cannot tell an empty Lua table from an empty array, +-- so only attach `hooks` when at least one hook was registered. Rust treats +-- the missing field as an empty list. +if #hooks > 0 then + envelope.hooks = hooks end -local okEncode, encoded = pcall(serde.encode, "json", config) +local okEncode, encoded = pcall(serde.encode, "json", envelope) if not okEncode then stdio.ewrite("[lithos] failed to encode user config as JSON: " .. tostring(encoded) .. "\n") process.exit(67) From 378f8d5074b1507fc1ca151bb0493177a13b7d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 17:39:14 -0600 Subject: [PATCH 03/10] docs(luau): document luau project configs with examples Adds a Luau-and-Lua section to the configuration page covering Lune installation, the return-table contract, repetition-reducing helpers, and the onConfigLoaded hook. Adds an examples/projects/luau-config sample that exercises the same ideas end-to-end. --- docs/site/pages/docs/configuration.mdx | 113 ++++++++++++++++++++-- examples/README.md | 1 + examples/projects/luau-config/README.md | 19 ++++ examples/projects/luau-config/lithos.luau | 67 +++++++++++++ 4 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 examples/projects/luau-config/README.md create mode 100644 examples/projects/luau-config/lithos.luau diff --git a/docs/site/pages/docs/configuration.mdx b/docs/site/pages/docs/configuration.mdx index 2ebc1dc..4e44d95 100644 --- a/docs/site/pages/docs/configuration.mdx +++ b/docs/site/pages/docs/configuration.mdx @@ -25,8 +25,9 @@ export async function getStaticProps() { # Configuration -Lithos configs can be written in YAML or JSON. This page covers file -discovery, path resolution, `outputs` defaults, and schema tooling. +Lithos configs can be written in YAML, JSON, or Luau / Lua. This page +covers file discovery, path resolution, `outputs` defaults, scripting +with Lune, and schema tooling. Looking for every supported key? Jump straight to the @@ -44,13 +45,14 @@ order: 1. If `PROJECT` is omitted, the current directory is the project. 2. If `PROJECT` is a directory, Lithos searches it for one of: - `lithos.yml`, `lithos.yaml`, `lithos.json`, then legacy `mantle.yml`, - `mantle.yaml`. + `lithos.yml`, `lithos.yaml`, `lithos.json`, `lithos.luau`, + `lithos.lua`, then legacy `mantle.yml`, `mantle.yaml`. 3. If `PROJECT` is a file, Lithos uses it directly. Explicit paths can - point to YAML or JSON. + point to YAML, JSON, Luau, or Lua. -When more than one discovered file exists, `lithos.*` always wins over -the legacy Mantle names. +When more than one discovered file exists, declarative formats win over +scripts (`.yml` > `.yaml` > `.json` > `.luau` > `.lua`), and `lithos.*` +always wins over the legacy Mantle names. ## File path resolution @@ -121,13 +123,106 @@ outputs: ## YAML and JSON Lithos accepts YAML and JSON interchangeably. Auto-discovery prefers -`lithos.yml` → `lithos.yaml` → `lithos.json`, then the legacy Mantle -names. JSONC and YAML anchors-in-comments are not supported. +`lithos.yml` → `lithos.yaml` → `lithos.json`, then `lithos.luau` → +`lithos.lua`, then the legacy Mantle names. JSONC and YAML +anchors-in-comments are not supported. If you need a YAML refresher, see [Learn YAML in Y Minutes](https://learnxinyminutes.com/docs/yaml/) or the [examples repo](https://github.com/siriuslatte/lithos/tree/main/examples). +## Luau and Lua + +For projects whose configuration would otherwise be repetitive +(many similar places, programmatically derived asset lists, +environment-driven branches), Lithos can evaluate a Luau or Lua file +instead of a static YAML / JSON document. + + + Luau configs are executed by [Lune](https://lune-org.github.io/docs), + an external Luau runtime. Install Lune (via + [Aftman](https://github.com/LPGhatguy/aftman), + [Foreman](https://github.com/Roblox/foreman), or your package manager) + and make sure the `lune` binary is on `PATH`. To pin a specific + binary, set `LITHOS_LUNE=/path/to/lune`. + + +Your script must `return` a table whose shape matches the same schema +the YAML and JSON configs use, or a table of the form +`{ config =
, ... }` (which leaves room for hooks, see below). + +```luau filename="project/lithos.luau" +local environments = { "production", "staging", "dev" } + +local config = { + environments = {}, + target = { + experience = { + places = { + start = { file = "game.rbxl" }, + }, + }, + }, +} + +for _, label in ipairs(environments) do + table.insert(config.environments, { + label = label, + branches = { label == "production" and "main" or label }, + }) +end + +return config +``` + +Because the file is just Luau, you can `require` shared helpers, read +environment variables via `@lune/process`, and pull in JSON / TOML +snippets through `@lune/serde` — exactly like any other Lune script. + +### Hooks + +Return a table with named hook functions alongside `config` to react +to Lithos lifecycle events. Today Lithos invokes one hook synchronously +at load time; additional hooks are recorded so future deploy steps can +route into them. + +```luau filename="project/lithos.luau" +return { + config = { + environments = { { label = "production", branches = { "main" } } }, + target = { + experience = { + places = { start = { file = "game.rbxl" } }, + }, + }, + }, + + -- Runs immediately after Lithos evaluates the config. Return a new + -- table to replace the loaded config, or `nil` to leave it as-is. + onConfigLoaded = function(config) + if os.getenv("CI") then + config.environments[1].branches = { "main", "release/*" } + end + return config + end, + + -- Registered for future deploy lifecycle integration. + onBeforeDeploy = function() end, + onAfterDeploy = function() end, +} +``` + + + Hook functions must use the `on` naming convention so Lithos + can distinguish them from helpers that happen to live at the top + level. Function values found anywhere else are stripped from the + config before validation. + + +If Lune cannot start, the user script raises an error, or the returned +table does not match the Lithos schema, Lithos surfaces the failure +together with the offending config path and Lune's stderr. + ## Editor schemas The published JSON schema powers autocomplete and validation in VS Code, diff --git a/examples/README.md b/examples/README.md index 560a342..fcc7b56 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,6 +5,7 @@ Runnable example projects for learning Lithos. | Project | What it shows | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | [`getting-started`](projects/getting-started) | The smallest valid `lithos.yml`: one experience, one place, two environments. | +| [`luau-config`](projects/luau-config) | A Luau-based `lithos.luau` with helpers, a loop over environments, and an `onConfigLoaded` hook. | | [`pirate-wars`](projects/pirate-wars) | A near-complete project: multi-place, icon, thumbnails, products, passes, badges, social links, notifications, asset bundle, env overrides. | ## Usage diff --git a/examples/projects/luau-config/README.md b/examples/projects/luau-config/README.md new file mode 100644 index 0000000..87a47dd --- /dev/null +++ b/examples/projects/luau-config/README.md @@ -0,0 +1,19 @@ +# Luau Config Example + +A small project that mirrors `getting-started` but defines the Lithos +config in Luau, demonstrating loops, helpers, and hook callbacks. + +```sh +# From the repository root +lithos deploy --environment dev examples/projects/luau-config +``` + +Requires the `lune` binary on PATH. See the +[Configuration docs](../../../docs/site/pages/docs/configuration.mdx) for +installation pointers and the full hook contract. + +The `game.rbxlx` referenced by `lithos.luau` is intentionally not +checked in here; copy +[`../getting-started/game.rbxlx`](../getting-started/game.rbxlx) into +this directory (or point the `file` field at your own place file) +before running `lithos deploy`. diff --git a/examples/projects/luau-config/lithos.luau b/examples/projects/luau-config/lithos.luau new file mode 100644 index 0000000..2d30a4b --- /dev/null +++ b/examples/projects/luau-config/lithos.luau @@ -0,0 +1,67 @@ +-- Luau equivalent of `examples/projects/getting-started/lithos.yml`, +-- demonstrating two reasons to reach for a Luau config: +-- +-- 1. Reduce repetition with helpers and loops. +-- 2. React to Lithos lifecycle events with hooks. +-- +-- Run with: `lithos deploy --environment dev examples/projects/luau-config`. +-- Requires the `lune` binary on PATH (see the Configuration docs page). + +local function environment(label: string, opts: { public: boolean? }?): { [string]: any } + opts = opts or {} + return { + label = label, + targetNamePrefix = if opts.public then nil else "environmentLabel", + targetAccess = if opts.public then "public" else nil, + } +end + +local config = { + environments = { + environment("dev"), + environment("staging"), + environment("prod", { public = true }), + }, + + target = { + experience = { + configuration = { + genre = "building", + playableDevices = { "computer", "phone", "tablet" }, + }, + places = { + start = { + file = "game.rbxlx", + configuration = { + name = "Getting Started with Lithos (Luau)", + description = "Made with Lithos and Luau.", + maxPlayerCount = 20, + }, + }, + }, + }, + }, +} + +return { + config = config, + + -- Runs synchronously right after Lithos finishes decoding the config. + -- Return a new table to replace it, or `nil` to leave it untouched. + onConfigLoaded = function(config) + if os.getenv("CI") then + -- In CI, automatically allow the `release/*` branch family to + -- deploy to production as well. + for _, env in ipairs(config.environments) do + if env.label == "prod" then + env.branches = { "main", "release/*" } + end + end + end + return config + end, + + -- Hooks below are recorded for future deploy lifecycle integration. + onBeforeDeploy = function() end, + onAfterDeploy = function() end, +} From bf6022703f300acde2f946023b89eee179ba0e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 22:22:04 -0600 Subject: [PATCH 04/10] docs(luau): add luau tab to every lithos config example Extends the YAML-to-tabs remark plugin (in both docs/site and docs/packages/lib) to emit a third Luau tab alongside YAML and JSON, generated from the parsed YAML value as a Luau return-table literal with idiomatic key formatting. Switches the existing inline luau fences to lang=lua so shiki highlights them. --- .../transform-lithos-config-examples.ts | 148 +++++++++++++++--- docs/site/pages/docs/commands.mdx | 2 +- docs/site/pages/docs/configuration.mdx | 4 +- .../transform-lithos-config-examples.js | 147 ++++++++++++++--- 4 files changed, 258 insertions(+), 43 deletions(-) diff --git a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts index 26d9822..31d194b 100644 --- a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts +++ b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts @@ -14,6 +14,13 @@ const PROJECT_CONFIG_KEYS = new Set([ 'state', ]); +// Reserved words that cannot be used as bare identifiers in Lua / Luau. +const LUAU_RESERVED = new Set([ + 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', + 'function', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', + 'return', 'then', 'true', 'until', 'while', 'continue', +]); + type CodeNode = { lang?: string; meta?: string; @@ -39,12 +46,19 @@ export function createTransformLithosConfigExamples( return; } - const jsonValue = convertYamlToJson(codeNode.value); - if (!jsonValue) { + const parsed = safeParseYaml(codeNode.value); + if (parsed === undefined) { return; } - parent.children.splice(index, 1, createTabsNode(codeNode, jsonValue)); + const jsonValue = JSON.stringify(parsed, null, 2); + const luauValue = convertToLuauReturn(parsed); + + parent.children.splice( + index, + 1, + createTabsNode(codeNode, jsonValue, luauValue) + ); }); }; }; @@ -110,32 +124,46 @@ function isLithosConfigFilename(filename: string) { return /(^|\/)lithos\.ya?ml$/.test(filename); } -function convertYamlToJson(yamlSource: string) { +function safeParseYaml(yamlSource: string): unknown | undefined { try { - const parsed = parseYaml(yamlSource); - return JSON.stringify(parsed, null, 2); + return parseYaml(yamlSource); } catch { return undefined; } } -function createTabsNode(codeNode: CodeNode, jsonValue: string) { +function createTabsNode(codeNode: CodeNode, jsonValue: string, luauValue: string) { return { type: 'mdxJsxFlowElement', name: 'ConfigFormatTabs', attributes: [], children: [ - createTabNode('YAML', createCodeNode(codeNode, codeNode.value, codeNode.meta)), + createTabNode( + 'YAML', + createCodeNode('yaml', codeNode.value, codeNode.meta) + ), createTabNode( 'JSON', - createCodeNode(codeNode, jsonValue, buildJsonMeta(codeNode.meta)) + createCodeNode( + 'json', + jsonValue, + rewriteFilenameMeta(codeNode.meta, 'lithos.json') + ) + ), + createTabNode( + 'Luau', + createCodeNode( + 'lua', + luauValue, + rewriteFilenameMeta(codeNode.meta, 'lithos.luau') + ) ), ], data: { _mdxExplicitJsx: true }, }; } -function createTabNode(label: string, codeNode: CodeNode) { +function createTabNode(label: string, codeNode: ReturnType) { return { type: 'mdxJsxFlowElement', name: 'ConfigFormatTab', @@ -145,26 +173,104 @@ function createTabNode(label: string, codeNode: CodeNode) { }; } -function createCodeNode(codeNode: CodeNode, value: string, meta?: string) { - return { - type: 'code', - lang: value === codeNode.value ? 'yaml' : 'json', - meta, - value, - }; +function createCodeNode(lang: string, value: string, meta?: string) { + return { type: 'code', lang, meta, value }; } -function buildJsonMeta(meta?: string) { +function rewriteFilenameMeta(meta: string | undefined, replacementName: string) { const filename = extractFilenameFromMeta(meta); if (!filename) { return undefined; } - const jsonFilename = filename.replace(/lithos\.ya?ml$/i, 'lithos.json'); - return `filename="${jsonFilename}"`; + const newFilename = filename.replace( + /lithos\.(ya?ml|json|luau|lua)$/i, + replacementName + ); + return `filename="${newFilename}"`; } function extractFilenameFromMeta(meta?: string) { const match = meta?.match(/(?:filename|title)="([^"]+)"/); return match?.[1]; -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// Luau pretty-printer. Mirrors the JavaScript port in +// `docs/site/remark-plugins/transform-lithos-config-examples.js`. +// --------------------------------------------------------------------------- + +function convertToLuauReturn(value: unknown): string { + return `return ${formatLuauValue(value, 0)}\n`; +} + +function formatLuauValue(value: unknown, indent: number): string { + if (value === null || value === undefined) { + return 'nil'; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : 'nil'; + } + if (typeof value === 'string') { + return formatLuauString(value); + } + if (Array.isArray(value)) { + return formatLuauArray(value, indent); + } + if (typeof value === 'object') { + return formatLuauObject(value as Record, indent); + } + return 'nil'; +} + +function formatLuauString(value: string): string { + if (!value.includes('\n')) { + return JSON.stringify(value); + } + + let level = 0; + while (value.includes(`]${'='.repeat(level)}]`)) { + level += 1; + } + const padding = '='.repeat(level); + return `[${padding}[\n${value}]${padding}]`; +} + +function formatLuauArray(array: unknown[], indent: number): string { + if (array.length === 0) { + return '{}'; + } + + const inner = ' '.repeat(indent + 1); + const close = ' '.repeat(indent); + const entries = array.map( + (item) => `${inner}${formatLuauValue(item, indent + 1)}` + ); + return `{\n${entries.join(',\n')},\n${close}}`; +} + +function formatLuauObject( + object: Record, + indent: number +): string { + const keys = Object.keys(object); + if (keys.length === 0) { + return '{}'; + } + + const inner = ' '.repeat(indent + 1); + const close = ' '.repeat(indent); + const entries = keys.map((key) => { + const formattedKey = isLuauIdentifier(key) ? key : `[${JSON.stringify(key)}]`; + const formattedValue = formatLuauValue(object[key], indent + 1); + return `${inner}${formattedKey} = ${formattedValue}`; + }); + return `{\n${entries.join(',\n')},\n${close}}`; +} + +function isLuauIdentifier(key: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +} diff --git a/docs/site/pages/docs/commands.mdx b/docs/site/pages/docs/commands.mdx index 3674c8b..1b978f4 100644 --- a/docs/site/pages/docs/commands.mdx +++ b/docs/site/pages/docs/commands.mdx @@ -192,7 +192,7 @@ To require outputs from game code, write a Luau module: lithos outputs --environment dev --output src/shared/generated/lithosOutputs.luau ``` -```luau +```lua local ReplicatedStorage = game:GetService("ReplicatedStorage") local outputs = require(ReplicatedStorage.shared.generated.lithosOutputs) diff --git a/docs/site/pages/docs/configuration.mdx b/docs/site/pages/docs/configuration.mdx index 4e44d95..94d70e5 100644 --- a/docs/site/pages/docs/configuration.mdx +++ b/docs/site/pages/docs/configuration.mdx @@ -151,7 +151,7 @@ Your script must `return` a table whose shape matches the same schema the YAML and JSON configs use, or a table of the form `{ config =
, ... }` (which leaves room for hooks, see below). -```luau filename="project/lithos.luau" +```lua filename="project/lithos.luau" local environments = { "production", "staging", "dev" } local config = { @@ -186,7 +186,7 @@ to Lithos lifecycle events. Today Lithos invokes one hook synchronously at load time; additional hooks are recorded so future deploy steps can route into them. -```luau filename="project/lithos.luau" +```lua filename="project/lithos.luau" return { config = { environments = { { label = "production", branches = { "main" } } }, diff --git a/docs/site/remark-plugins/transform-lithos-config-examples.js b/docs/site/remark-plugins/transform-lithos-config-examples.js index c13e516..34c331d 100644 --- a/docs/site/remark-plugins/transform-lithos-config-examples.js +++ b/docs/site/remark-plugins/transform-lithos-config-examples.js @@ -9,6 +9,13 @@ const PROJECT_CONFIG_KEYS = new Set([ 'state', ]); +// Reserved words that cannot be used as bare identifiers in Lua / Luau. +const LUAU_RESERVED = new Set([ + 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', + 'function', 'if', 'in', 'local', 'nil', 'not', 'or', 'repeat', + 'return', 'then', 'true', 'until', 'while', 'continue', +]); + function createTransformLithosConfigExamples(options = {}) { const mode = options.mode ?? 'project'; @@ -25,12 +32,19 @@ function createTransformLithosConfigExamples(options = {}) { return; } - const jsonValue = convertYamlToJson(node.value); - if (!jsonValue) { + const parsed = safeParseYaml(node.value); + if (parsed === undefined) { return; } - parent.children.splice(index, 1, createTabsNode(node, jsonValue)); + const jsonValue = JSON.stringify(parsed, null, 2); + const luauValue = convertToLuauReturn(parsed); + + parent.children.splice( + index, + 1, + createTabsNode(node, jsonValue, luauValue) + ); }); }; }; @@ -94,25 +108,39 @@ function isLithosConfigFilename(filename) { return /(^|\/)lithos\.ya?ml$/.test(filename); } -function convertYamlToJson(yamlSource) { +function safeParseYaml(yamlSource) { try { - const parsed = parseYaml(yamlSource); - return JSON.stringify(parsed, null, 2); + return parseYaml(yamlSource); } catch { return undefined; } } -function createTabsNode(codeNode, jsonValue) { +function createTabsNode(codeNode, jsonValue, luauValue) { return { type: 'mdxJsxFlowElement', name: 'ConfigFormatTabs', attributes: [], children: [ - createTabNode('YAML', createCodeNode(codeNode, codeNode.value, codeNode.meta)), + createTabNode( + 'YAML', + createCodeNode('yaml', codeNode.value, codeNode.meta) + ), createTabNode( 'JSON', - createCodeNode(codeNode, jsonValue, buildJsonMeta(codeNode.meta)) + createCodeNode( + 'json', + jsonValue, + rewriteFilenameMeta(codeNode.meta, 'lithos.json') + ) + ), + createTabNode( + 'Luau', + createCodeNode( + 'lua', + luauValue, + rewriteFilenameMeta(codeNode.meta, 'lithos.luau') + ) ), ], data: { _mdxExplicitJsx: true }, @@ -129,23 +157,21 @@ function createTabNode(label, codeNode) { }; } -function createCodeNode(codeNode, value, meta) { - return { - type: 'code', - lang: value === codeNode.value ? 'yaml' : 'json', - meta, - value, - }; +function createCodeNode(lang, value, meta) { + return { type: 'code', lang, meta, value }; } -function buildJsonMeta(meta) { +function rewriteFilenameMeta(meta, replacementName) { const filename = extractFilenameFromMeta(meta); if (!filename) { return undefined; } - const jsonFilename = filename.replace(/lithos\.ya?ml$/i, 'lithos.json'); - return `filename="${jsonFilename}"`; + const newFilename = filename.replace( + /lithos\.(ya?ml|json|luau|lua)$/i, + replacementName + ); + return `filename="${newFilename}"`; } function extractFilenameFromMeta(meta) { @@ -153,6 +179,89 @@ function extractFilenameFromMeta(meta) { return match?.[1]; } +// --------------------------------------------------------------------------- +// Luau pretty-printer. +// +// Converts the JSON-equivalent value parsed from a Lithos YAML config into +// a Luau `return
` source snippet. We aim for idiomatic output: +// bare identifiers for keys that already look like identifiers, double-quoted +// strings, long brackets for multi-line strings, and two-space indentation. +// --------------------------------------------------------------------------- + +function convertToLuauReturn(value) { + return `return ${formatLuauValue(value, 0)}\n`; +} + +function formatLuauValue(value, indent) { + if (value === null || value === undefined) { + return 'nil'; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : 'nil'; + } + if (typeof value === 'string') { + return formatLuauString(value); + } + if (Array.isArray(value)) { + return formatLuauArray(value, indent); + } + if (typeof value === 'object') { + return formatLuauObject(value, indent); + } + return 'nil'; +} + +function formatLuauString(value) { + if (!value.includes('\n')) { + return JSON.stringify(value); + } + + // Use Lua long brackets for multi-line strings, picking an equals padding + // that does not appear in the body so we never accidentally close early. + let level = 0; + while (value.includes(`]${'='.repeat(level)}]`)) { + level += 1; + } + const padding = '='.repeat(level); + return `[${padding}[\n${value}]${padding}]`; +} + +function formatLuauArray(array, indent) { + if (array.length === 0) { + return '{}'; + } + + const inner = ' '.repeat(indent + 1); + const close = ' '.repeat(indent); + const entries = array.map( + (item) => `${inner}${formatLuauValue(item, indent + 1)}` + ); + return `{\n${entries.join(',\n')},\n${close}}`; +} + +function formatLuauObject(object, indent) { + const keys = Object.keys(object); + if (keys.length === 0) { + return '{}'; + } + + const inner = ' '.repeat(indent + 1); + const close = ' '.repeat(indent); + const entries = keys.map((key) => { + const formattedKey = isLuauIdentifier(key) ? key : `[${JSON.stringify(key)}]`; + const formattedValue = formatLuauValue(object[key], indent + 1); + return `${inner}${formattedKey} = ${formattedValue}`; + }); + return `{\n${entries.join(',\n')},\n${close}}`; +} + +function isLuauIdentifier(key) { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +} + module.exports = { createTransformLithosConfigExamples, }; \ No newline at end of file From 5730094d13a8eff10693990b85c85a378aed58c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 22:31:22 -0600 Subject: [PATCH 05/10] docs(luau): persist config-format tab choice and explain luau validation Pass storageKey to nextra Tabs so picking the Luau (or JSON) tab on any Lithos config example carries across pages, since SSR only renders the active tab panel. Adds a 'What about Luau and Lua?' subsection explaining that the JSON schema validates the runtime-evaluated Luau table and recommending luau-lsp for in-file authoring support. --- docs/site/components/config-format-tabs.tsx | 5 +++- docs/site/pages/docs/configuration.mdx | 33 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/site/components/config-format-tabs.tsx b/docs/site/components/config-format-tabs.tsx index 9d2e61a..847ce51 100644 --- a/docs/site/components/config-format-tabs.tsx +++ b/docs/site/components/config-format-tabs.tsx @@ -29,7 +29,10 @@ export function ConfigFormatTabs({ children }: { children: ReactNode }) { } return ( - item.props.label ?? 'Example')}> + item.props.label ?? 'Example')} + storageKey="lithos-config-format" + > {items.map((item, index) => ( {item.props.children} ))} diff --git a/docs/site/pages/docs/configuration.mdx b/docs/site/pages/docs/configuration.mdx index 94d70e5..43272e2 100644 --- a/docs/site/pages/docs/configuration.mdx +++ b/docs/site/pages/docs/configuration.mdx @@ -236,6 +236,39 @@ for YAML files and add the snippet below to your settings: +### What about Luau and Lua? + +The JSON schema validates the *shape* of a Lithos config — string vs. +number, required keys, allowed enum values. A Luau / Lua script is +code, not data, so editors cannot apply the JSON schema to it the way +they do to YAML or JSON. + +Validation still happens, just at a different point: + +1. Lune evaluates the script and returns a table. +2. Lithos converts the table to JSON internally and validates it + against the same schema used for YAML and JSON. +3. Any schema violation is reported with the offending config path and + the failing field, the same way YAML and JSON errors are reported. + +That means a `lithos.luau` file with a misspelled key or a wrong-typed +value will fail at config-load time with a normal Lithos error — not +silently produce a broken deploy. + +For editor support inside the `.luau` file itself, use +[luau-lsp](https://github.com/JohnnyMorganz/luau-lsp) the same way you +would for any other Lune script. It type-checks Luau syntax, function +calls, and `require`d modules, but it does not know about the Lithos +config shape — treat the returned table as a plain Luau table and rely +on Lithos's runtime validation for shape errors. + + + If you want schema-driven autocomplete while authoring, write the + body of your config in `lithos.json` (or `lithos.yml`) and only reach + for `lithos.luau` when you need real logic — environment fan-out, + conditional branches, computed asset lists, or hooks. + + ## Next steps Date: Fri, 22 May 2026 22:50:32 -0600 Subject: [PATCH 06/10] docs(luau): translate line-highlight meta across yaml/json/luau tabs Walks the YAML AST to map every input line to a key path, then re-emits JSON and Luau with per-path line tracking so a YAML `{4,6-7,10}` highlight becomes the equivalent `{5,7-8,13}` in the JSON and Luau tabs. Previously the meta was copied verbatim, which placed the highlight bar on the wrong rows. --- .../transform-lithos-config-examples.ts | 515 ++++++++++++++---- .../transform-lithos-config-examples.js | 488 +++++++++++++---- 2 files changed, 802 insertions(+), 201 deletions(-) diff --git a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts index 31d194b..f324dae 100644 --- a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts +++ b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts @@ -1,4 +1,11 @@ -import { parse as parseYaml } from 'yaml'; +import { + parse as parseYaml, + parseDocument, + isMap, + isSeq, + isScalar, + isPair, +} from 'yaml'; import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; @@ -27,6 +34,24 @@ type CodeNode = { value: string; }; +type PathSegment = string | number; +type Path = PathSegment[]; + +type YamlInfo = { + value: unknown; + lineToPath: Map; +}; + +type BuilderOutput = { + text: string; + pathToLine: Map; +}; + +type Highlight = { + raw: string; + lines: Set; +}; + export function createTransformLithosConfigExamples( options: TransformLithosConfigExamplesOptions = {} ): Plugin<[]> { @@ -46,18 +71,18 @@ export function createTransformLithosConfigExamples( return; } - const parsed = safeParseYaml(codeNode.value); - if (parsed === undefined) { + const yamlInfo = parseYamlWithLineMap(codeNode.value); + if (yamlInfo === undefined) { return; } - const jsonValue = JSON.stringify(parsed, null, 2); - const luauValue = convertToLuauReturn(parsed); + const jsonOutput = buildJsonWithPathLines(yamlInfo.value); + const luauOutput = buildLuauWithPathLines(yamlInfo.value); parent.children.splice( index, 1, - createTabsNode(codeNode, jsonValue, luauValue) + createTabsNode(codeNode, yamlInfo, jsonOutput, luauOutput) ); }); }; @@ -124,106 +149,227 @@ function isLithosConfigFilename(filename: string) { return /(^|\/)lithos\.ya?ml$/.test(filename); } -function safeParseYaml(yamlSource: string): unknown | undefined { +// --------------------------------------------------------------------------- +// YAML parsing with per-line path tracking. Walks the YAML AST and records +// the deepest key / value path that begins on each input line. The result +// powers `{N}` line-highlight translation across formats. +// --------------------------------------------------------------------------- + +function parseYamlWithLineMap(source: string): YamlInfo | undefined { + let doc; try { - return parseYaml(yamlSource); + doc = parseDocument(source); } catch { return undefined; } -} + if (doc.errors && doc.errors.length > 0) { + return undefined; + } -function createTabsNode(codeNode: CodeNode, jsonValue: string, luauValue: string) { - return { - type: 'mdxJsxFlowElement', - name: 'ConfigFormatTabs', - attributes: [], - children: [ - createTabNode( - 'YAML', - createCodeNode('yaml', codeNode.value, codeNode.meta) - ), - createTabNode( - 'JSON', - createCodeNode( - 'json', - jsonValue, - rewriteFilenameMeta(codeNode.meta, 'lithos.json') - ) - ), - createTabNode( - 'Luau', - createCodeNode( - 'lua', - luauValue, - rewriteFilenameMeta(codeNode.meta, 'lithos.luau') - ) - ), - ], - data: { _mdxExplicitJsx: true }, - }; -} + const value = doc.toJS(); + if (value === undefined || value === null) { + return undefined; + } -function createTabNode(label: string, codeNode: ReturnType) { - return { - type: 'mdxJsxFlowElement', - name: 'ConfigFormatTab', - attributes: [{ type: 'mdxJsxAttribute', name: 'label', value: label }], - children: [codeNode], - data: { _mdxExplicitJsx: true }, + const lineStarts = [0]; + for (let i = 0; i < source.length; i += 1) { + if (source[i] === '\n') { + lineStarts.push(i + 1); + } + } + const offsetToLine = (offset: number) => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineStarts[mid]! <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo + 1; }; -} -function createCodeNode(lang: string, value: string, meta?: string) { - return { type: 'code', lang, meta, value }; -} + const lineToPath = new Map(); + const setLine = (line: number, path: Path) => { + const existing = lineToPath.get(line); + if (!existing || path.length > existing.length) { + lineToPath.set(line, path.slice()); + } + }; -function rewriteFilenameMeta(meta: string | undefined, replacementName: string) { - const filename = extractFilenameFromMeta(meta); - if (!filename) { - return undefined; + function walk(node: any, path: Path) { + if (!node) return; + if (isMap(node)) { + for (const pair of node.items as any[]) { + if (!isPair(pair)) continue; + const key = (pair.key as any) && (pair.key as any).value; + if (key == null) continue; + const childPath: Path = [...path, String(key)]; + if ((pair.key as any) && (pair.key as any).range) { + setLine(offsetToLine((pair.key as any).range[0]), childPath); + } + walk(pair.value, childPath); + } + } else if (isSeq(node)) { + (node.items as any[]).forEach((item, idx) => { + const childPath: Path = [...path, idx]; + if (item && item.range) { + setLine(offsetToLine(item.range[0]), childPath); + } + walk(item, childPath); + }); + } else if (isScalar(node)) { + if ((node as any).range) { + setLine(offsetToLine((node as any).range[0]), path); + } + } } + walk(doc.contents, []); - const newFilename = filename.replace( - /lithos\.(ya?ml|json|luau|lua)$/i, - replacementName - ); - return `filename="${newFilename}"`; + return { value, lineToPath }; } -function extractFilenameFromMeta(meta?: string) { - const match = meta?.match(/(?:filename|title)="([^"]+)"/); - return match?.[1]; +function pathKey(path: Path) { + return path.map((segment) => String(segment)).join('\u0000'); } // --------------------------------------------------------------------------- -// Luau pretty-printer. Mirrors the JavaScript port in -// `docs/site/remark-plugins/transform-lithos-config-examples.js`. +// JSON / Luau emitters with per-path line tracking. // --------------------------------------------------------------------------- -function convertToLuauReturn(value: unknown): string { - return `return ${formatLuauValue(value, 0)}\n`; +function buildJsonWithPathLines(rootValue: unknown): BuilderOutput { + const builder = new LineBuilder(); + emitJsonValue(builder, rootValue, [], 0); + return builder.finish(); +} + +function emitJsonValue( + builder: LineBuilder, + value: unknown, + path: Path, + indent: number +) { + if (Array.isArray(value)) { + if (value.length === 0) { + builder.write('[]'); + return; + } + builder.write('['); + builder.newline(); + value.forEach((item, i) => { + const childPath: Path = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitJsonValue(builder, item, childPath, indent + 1); + if (i < value.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + ']'); + return; + } + if (value && typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath: Path = [...path, key]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1) + JSON.stringify(key) + ': '); + emitJsonValue(builder, obj[key], childPath, indent + 1); + if (i < keys.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + builder.write(formatJsonScalar(value)); } -function formatLuauValue(value: unknown, indent: number): string { +function formatJsonScalar(value: unknown): string { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' && !Number.isFinite(value)) return 'null'; + return JSON.stringify(value); +} + +function buildLuauWithPathLines(rootValue: unknown): BuilderOutput { + const builder = new LineBuilder(); + builder.write('return '); + emitLuauValue(builder, rootValue, [], 0); + builder.newline(); + return builder.finish(); +} + +function emitLuauValue( + builder: LineBuilder, + value: unknown, + path: Path, + indent: number +) { if (value === null || value === undefined) { - return 'nil'; + builder.write('nil'); + return; } if (typeof value === 'boolean') { - return value ? 'true' : 'false'; + builder.write(value ? 'true' : 'false'); + return; } if (typeof value === 'number') { - return Number.isFinite(value) ? String(value) : 'nil'; + builder.write(Number.isFinite(value) ? String(value) : 'nil'); + return; } if (typeof value === 'string') { - return formatLuauString(value); + builder.write(formatLuauString(value)); + return; } if (Array.isArray(value)) { - return formatLuauArray(value, indent); + if (value.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + value.forEach((item, i) => { + const childPath: Path = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitLuauValue(builder, item, childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; } if (typeof value === 'object') { - return formatLuauObject(value as Record, indent); + const obj = value as Record; + const keys = Object.keys(obj); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath: Path = [...path, key]; + builder.recordPathOnNextLine(childPath); + const formattedKey = isLuauIdentifier(key) + ? key + : `[${JSON.stringify(key)}]`; + builder.write(' '.repeat(indent + 1) + formattedKey + ' = '); + emitLuauValue(builder, obj[key], childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; } - return 'nil'; + builder.write('nil'); } function formatLuauString(value: string): string { @@ -239,38 +385,207 @@ function formatLuauString(value: string): string { return `[${padding}[\n${value}]${padding}]`; } -function formatLuauArray(array: unknown[], indent: number): string { - if (array.length === 0) { - return '{}'; +function isLuauIdentifier(key: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +} + +class LineBuilder { + private lines: string[] = []; + private current = ''; + private pathToLine = new Map(); + + write(s: string) { + this.current += s; + } + newline() { + this.lines.push(this.current); + this.current = ''; } + recordPathOnNextLine(path: Path) { + const key = pathKey(path); + if (!this.pathToLine.has(key)) { + this.pathToLine.set(key, this.lines.length + 1); + } + } + finish(): BuilderOutput { + if (this.current.length > 0) { + this.lines.push(this.current); + this.current = ''; + } + return { text: this.lines.join('\n') + '\n', pathToLine: this.pathToLine }; + } +} - const inner = ' '.repeat(indent + 1); - const close = ' '.repeat(indent); - const entries = array.map( - (item) => `${inner}${formatLuauValue(item, indent + 1)}` - ); - return `{\n${entries.join(',\n')},\n${close}}`; +// --------------------------------------------------------------------------- +// Highlight translation. +// --------------------------------------------------------------------------- + +function parseHighlightMeta(meta: string | undefined): Highlight | null { + if (!meta) return null; + const match = meta.match(/\{([^}]+)\}/); + if (!match || match[1] === undefined) return null; + const lines = new Set(); + for (const part of match[1].split(',')) { + const trimmed = part.trim(); + const rangeMatch = trimmed.match(/^(\d+)\s*-\s*(\d+)$/); + if (rangeMatch && rangeMatch[1] !== undefined && rangeMatch[2] !== undefined) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + for (let i = start; i <= end; i += 1) { + lines.add(i); + } + } else if (/^\d+$/.test(trimmed)) { + lines.add(parseInt(trimmed, 10)); + } + } + return { raw: match[0], lines }; } -function formatLuauObject( - object: Record, - indent: number -): string { - const keys = Object.keys(object); - if (keys.length === 0) { - return '{}'; +function formatHighlight(lineSet: Set): string { + if (!lineSet || lineSet.size === 0) return ''; + const sorted = [...lineSet].sort((a, b) => a - b); + const parts: string[] = []; + let i = 0; + while (i < sorted.length) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1]! === sorted[j]! + 1) { + j += 1; + } + parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`); + i = j + 1; } + return `{${parts.join(',')}}`; +} - const inner = ' '.repeat(indent + 1); - const close = ' '.repeat(indent); - const entries = keys.map((key) => { - const formattedKey = isLuauIdentifier(key) ? key : `[${JSON.stringify(key)}]`; - const formattedValue = formatLuauValue(object[key], indent + 1); - return `${inner}${formattedKey} = ${formattedValue}`; - }); - return `{\n${entries.join(',\n')},\n${close}}`; +function translateHighlight( + originalHighlight: Highlight | null, + yamlLineToPath: Map, + targetPathToLine: Map +): Set { + const result = new Set(); + if (!originalHighlight) return result; + for (const line of originalHighlight.lines) { + const path = yamlLineToPath.get(line); + if (!path) continue; + const targetLine = targetPathToLine.get(pathKey(path)); + if (targetLine !== undefined) { + result.add(targetLine); + } + } + return result; } -function isLuauIdentifier(key: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +// --------------------------------------------------------------------------- +// Tab construction. +// --------------------------------------------------------------------------- + +function createTabsNode( + codeNode: CodeNode, + yamlInfo: YamlInfo, + jsonOutput: BuilderOutput, + luauOutput: BuilderOutput +) { + const originalHighlight = parseHighlightMeta(codeNode.meta); + + const jsonHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + jsonOutput.pathToLine + ) + ) + : ''; + const luauHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + luauOutput.pathToLine + ) + ) + : ''; + + return { + type: 'mdxJsxFlowElement', + name: 'ConfigFormatTabs', + attributes: [], + children: [ + createTabNode( + 'YAML', + createCodeNode('yaml', codeNode.value, codeNode.meta) + ), + createTabNode( + 'JSON', + createCodeNode( + 'json', + jsonOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.json', jsonHighlight) + ) + ), + createTabNode( + 'Luau', + createCodeNode( + 'lua', + luauOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.luau', luauHighlight) + ) + ), + ], + data: { _mdxExplicitJsx: true }, + }; +} + +function createTabNode(label: string, codeNode: ReturnType) { + return { + type: 'mdxJsxFlowElement', + name: 'ConfigFormatTab', + attributes: [{ type: 'mdxJsxAttribute', name: 'label', value: label }], + children: [codeNode], + data: { _mdxExplicitJsx: true }, + }; +} + +function createCodeNode(lang: string, value: string, meta?: string) { + return { type: 'code', lang, meta, value }; +} + +function rewriteMetaForFormat( + meta: string | undefined, + replacementName: string, + newHighlightStr: string +): string | undefined { + if (!meta) { + return newHighlightStr || undefined; + } + + let result = meta; + const filename = extractFilenameFromMeta(meta); + if (filename) { + const newFilename = filename.replace( + /lithos\.(ya?ml|json|luau|lua)$/i, + replacementName + ); + result = result.replace( + /(filename|title)="[^"]+"/, + `filename="${newFilename}"` + ); + } + + if (/\{[^}]+\}/.test(result)) { + result = result.replace( + /\s*\{[^}]+\}/, + newHighlightStr ? ` ${newHighlightStr}` : '' + ); + } else if (newHighlightStr) { + result = `${result.trim()} ${newHighlightStr}`; + } + + result = result.trim(); + return result.length > 0 ? result : undefined; +} + +function extractFilenameFromMeta(meta?: string) { + const match = meta?.match(/(?:filename|title)="([^"]+)"/); + return match?.[1]; } diff --git a/docs/site/remark-plugins/transform-lithos-config-examples.js b/docs/site/remark-plugins/transform-lithos-config-examples.js index 34c331d..d63d27f 100644 --- a/docs/site/remark-plugins/transform-lithos-config-examples.js +++ b/docs/site/remark-plugins/transform-lithos-config-examples.js @@ -1,4 +1,11 @@ -const { parse: parseYaml } = require('yaml'); +const { + parse: parseYaml, + parseDocument, + isMap, + isSeq, + isScalar, + isPair, +} = require('yaml'); const { visit } = require('unist-util-visit'); const PROJECT_CONFIG_KEYS = new Set([ @@ -32,18 +39,18 @@ function createTransformLithosConfigExamples(options = {}) { return; } - const parsed = safeParseYaml(node.value); - if (parsed === undefined) { + const yamlInfo = parseYamlWithLineMap(node.value); + if (yamlInfo === undefined) { return; } - const jsonValue = JSON.stringify(parsed, null, 2); - const luauValue = convertToLuauReturn(parsed); + const jsonOutput = buildJsonWithPathLines(yamlInfo.value); + const luauOutput = buildLuauWithPathLines(yamlInfo.value); parent.children.splice( index, 1, - createTabsNode(node, jsonValue, luauValue) + createTabsNode(node, yamlInfo, jsonOutput, luauOutput) ); }); }; @@ -108,110 +115,226 @@ function isLithosConfigFilename(filename) { return /(^|\/)lithos\.ya?ml$/.test(filename); } -function safeParseYaml(yamlSource) { +// --------------------------------------------------------------------------- +// YAML parsing with per-line path tracking. +// +// We walk the YAML AST (via `parseDocument`) and record, for each input line, +// the deepest key/value path that begins on that line. This lets us translate +// a user's `{4,6-7}` line-highlight meta into the equivalent lines in the +// generated JSON / Luau tabs. +// --------------------------------------------------------------------------- + +function parseYamlWithLineMap(source) { + let doc; try { - return parseYaml(yamlSource); + doc = parseDocument(source); } catch { return undefined; } -} + if (doc.errors && doc.errors.length > 0) { + return undefined; + } -function createTabsNode(codeNode, jsonValue, luauValue) { - return { - type: 'mdxJsxFlowElement', - name: 'ConfigFormatTabs', - attributes: [], - children: [ - createTabNode( - 'YAML', - createCodeNode('yaml', codeNode.value, codeNode.meta) - ), - createTabNode( - 'JSON', - createCodeNode( - 'json', - jsonValue, - rewriteFilenameMeta(codeNode.meta, 'lithos.json') - ) - ), - createTabNode( - 'Luau', - createCodeNode( - 'lua', - luauValue, - rewriteFilenameMeta(codeNode.meta, 'lithos.luau') - ) - ), - ], - data: { _mdxExplicitJsx: true }, - }; -} + const value = doc.toJS(); + if (value === undefined || value === null) { + return undefined; + } -function createTabNode(label, codeNode) { - return { - type: 'mdxJsxFlowElement', - name: 'ConfigFormatTab', - attributes: [{ type: 'mdxJsxAttribute', name: 'label', value: label }], - children: [codeNode], - data: { _mdxExplicitJsx: true }, + const lineStarts = [0]; + for (let i = 0; i < source.length; i += 1) { + if (source[i] === '\n') { + lineStarts.push(i + 1); + } + } + const offsetToLine = (offset) => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >>> 1; + if (lineStarts[mid] <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo + 1; }; -} -function createCodeNode(lang, value, meta) { - return { type: 'code', lang, meta, value }; -} + // For each line, record the deepest path that starts there. Walking + // depth-first means deeper paths overwrite shallower ones at the same line. + const lineToPath = new Map(); + const setLine = (line, path) => { + const existing = lineToPath.get(line); + if (!existing || path.length > existing.length) { + lineToPath.set(line, path.slice()); + } + }; -function rewriteFilenameMeta(meta, replacementName) { - const filename = extractFilenameFromMeta(meta); - if (!filename) { - return undefined; + function walk(node, path) { + if (!node) return; + if (isMap(node)) { + for (const pair of node.items) { + if (!isPair(pair)) continue; + const key = pair.key && pair.key.value; + if (key == null) continue; + const childPath = [...path, String(key)]; + if (pair.key && pair.key.range) { + setLine(offsetToLine(pair.key.range[0]), childPath); + } + walk(pair.value, childPath); + } + } else if (isSeq(node)) { + node.items.forEach((item, idx) => { + const childPath = [...path, idx]; + if (item && item.range) { + setLine(offsetToLine(item.range[0]), childPath); + } + walk(item, childPath); + }); + } else if (isScalar(node)) { + if (node.range) { + setLine(offsetToLine(node.range[0]), path); + } + } } + walk(doc.contents, []); - const newFilename = filename.replace( - /lithos\.(ya?ml|json|luau|lua)$/i, - replacementName - ); - return `filename="${newFilename}"`; + return { value, lineToPath }; } -function extractFilenameFromMeta(meta) { - const match = meta?.match(/(?:filename|title)="([^"]+)"/); - return match?.[1]; +function pathKey(path) { + return path.map((segment) => String(segment)).join('\u0000'); } // --------------------------------------------------------------------------- -// Luau pretty-printer. +// JSON / Luau emitters with per-path line tracking. // -// Converts the JSON-equivalent value parsed from a Lithos YAML config into -// a Luau `return
` source snippet. We aim for idiomatic output: -// bare identifiers for keys that already look like identifiers, double-quoted -// strings, long brackets for multi-line strings, and two-space indentation. +// Each emitter produces { text, pathToLine }. `pathToLine` maps a `pathKey` +// (the `\0`-joined path from the root) to the 1-indexed output line where +// that key / value pair starts. The line-highlight translator looks up the +// YAML path for each highlighted YAML line, then looks up the corresponding +// line in the target format. // --------------------------------------------------------------------------- -function convertToLuauReturn(value) { - return `return ${formatLuauValue(value, 0)}\n`; +function buildJsonWithPathLines(rootValue) { + const builder = new LineBuilder(); + emitJsonValue(builder, rootValue, [], 0); + return builder.finish(); +} + +function emitJsonValue(builder, value, path, indent) { + if (Array.isArray(value)) { + if (value.length === 0) { + builder.write('[]'); + return; + } + builder.write('['); + builder.newline(); + value.forEach((item, i) => { + const childPath = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitJsonValue(builder, item, childPath, indent + 1); + if (i < value.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + ']'); + return; + } + if (value && typeof value === 'object') { + const keys = Object.keys(value); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath = [...path, key]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1) + JSON.stringify(key) + ': '); + emitJsonValue(builder, value[key], childPath, indent + 1); + if (i < keys.length - 1) builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; + } + builder.write(formatJsonScalar(value)); +} + +function formatJsonScalar(value) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' && !Number.isFinite(value)) return 'null'; + return JSON.stringify(value); } -function formatLuauValue(value, indent) { +function buildLuauWithPathLines(rootValue) { + const builder = new LineBuilder(); + builder.write('return '); + emitLuauValue(builder, rootValue, [], 0); + builder.newline(); + return builder.finish(); +} + +function emitLuauValue(builder, value, path, indent) { if (value === null || value === undefined) { - return 'nil'; + builder.write('nil'); + return; } if (typeof value === 'boolean') { - return value ? 'true' : 'false'; + builder.write(value ? 'true' : 'false'); + return; } if (typeof value === 'number') { - return Number.isFinite(value) ? String(value) : 'nil'; + builder.write(Number.isFinite(value) ? String(value) : 'nil'); + return; } if (typeof value === 'string') { - return formatLuauString(value); + builder.write(formatLuauString(value)); + return; } if (Array.isArray(value)) { - return formatLuauArray(value, indent); + if (value.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + value.forEach((item, i) => { + const childPath = [...path, i]; + builder.recordPathOnNextLine(childPath); + builder.write(' '.repeat(indent + 1)); + emitLuauValue(builder, item, childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; } if (typeof value === 'object') { - return formatLuauObject(value, indent); + const keys = Object.keys(value); + if (keys.length === 0) { + builder.write('{}'); + return; + } + builder.write('{'); + builder.newline(); + keys.forEach((key, i) => { + const childPath = [...path, key]; + builder.recordPathOnNextLine(childPath); + const formattedKey = isLuauIdentifier(key) + ? key + : `[${JSON.stringify(key)}]`; + builder.write(' '.repeat(indent + 1) + formattedKey + ' = '); + emitLuauValue(builder, value[key], childPath, indent + 1); + builder.write(','); + builder.newline(); + }); + builder.write(' '.repeat(indent) + '}'); + return; } - return 'nil'; + builder.write('nil'); } function formatLuauString(value) { @@ -229,39 +352,202 @@ function formatLuauString(value) { return `[${padding}[\n${value}]${padding}]`; } -function formatLuauArray(array, indent) { - if (array.length === 0) { - return '{}'; +function isLuauIdentifier(key) { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +} + +class LineBuilder { + constructor() { + this.lines = []; + this.current = ''; + this.pathToLine = new Map(); + } + write(s) { + this.current += s; + } + newline() { + this.lines.push(this.current); + this.current = ''; + } + recordPathOnNextLine(path) { + const key = pathKey(path); + if (!this.pathToLine.has(key)) { + this.pathToLine.set(key, this.lines.length + 1); + } + } + finish() { + if (this.current.length > 0) { + this.lines.push(this.current); + this.current = ''; + } + return { text: this.lines.join('\n') + '\n', pathToLine: this.pathToLine }; + } +} + +// --------------------------------------------------------------------------- +// Highlight translation. Parses the `{N,M-O}` portion of the YAML meta, maps +// each highlighted YAML line through the path table to the equivalent line +// in the JSON / Luau output, and serializes the result back into the same +// `{...}` syntax that Nextra / shiki expects. +// --------------------------------------------------------------------------- + +function parseHighlightMeta(meta) { + if (!meta) return null; + const match = meta.match(/\{([^}]+)\}/); + if (!match) return null; + const lines = new Set(); + for (const part of match[1].split(',')) { + const trimmed = part.trim(); + const rangeMatch = trimmed.match(/^(\d+)\s*-\s*(\d+)$/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1], 10); + const end = parseInt(rangeMatch[2], 10); + for (let i = start; i <= end; i += 1) { + lines.add(i); + } + } else if (/^\d+$/.test(trimmed)) { + lines.add(parseInt(trimmed, 10)); + } + } + return { raw: match[0], lines }; +} + +function formatHighlight(lineSet) { + if (!lineSet || lineSet.size === 0) return ''; + const sorted = [...lineSet].sort((a, b) => a - b); + const parts = []; + let i = 0; + while (i < sorted.length) { + let j = i; + while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1) { + j += 1; + } + parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`); + i = j + 1; } + return `{${parts.join(',')}}`; +} - const inner = ' '.repeat(indent + 1); - const close = ' '.repeat(indent); - const entries = array.map( - (item) => `${inner}${formatLuauValue(item, indent + 1)}` - ); - return `{\n${entries.join(',\n')},\n${close}}`; +function translateHighlight(originalHighlight, yamlLineToPath, targetPathToLine) { + const result = new Set(); + if (!originalHighlight) return result; + for (const line of originalHighlight.lines) { + const path = yamlLineToPath.get(line); + if (!path) continue; + const targetLine = targetPathToLine.get(pathKey(path)); + if (targetLine !== undefined) { + result.add(targetLine); + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Tab construction. +// --------------------------------------------------------------------------- + +function createTabsNode(codeNode, yamlInfo, jsonOutput, luauOutput) { + const originalHighlight = parseHighlightMeta(codeNode.meta); + + const jsonHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + jsonOutput.pathToLine + ) + ) + : ''; + const luauHighlight = originalHighlight + ? formatHighlight( + translateHighlight( + originalHighlight, + yamlInfo.lineToPath, + luauOutput.pathToLine + ) + ) + : ''; + + return { + type: 'mdxJsxFlowElement', + name: 'ConfigFormatTabs', + attributes: [], + children: [ + createTabNode( + 'YAML', + createCodeNode('yaml', codeNode.value, codeNode.meta) + ), + createTabNode( + 'JSON', + createCodeNode( + 'json', + jsonOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.json', jsonHighlight) + ) + ), + createTabNode( + 'Luau', + createCodeNode( + 'lua', + luauOutput.text, + rewriteMetaForFormat(codeNode.meta, 'lithos.luau', luauHighlight) + ) + ), + ], + data: { _mdxExplicitJsx: true }, + }; } -function formatLuauObject(object, indent) { - const keys = Object.keys(object); - if (keys.length === 0) { - return '{}'; +function createTabNode(label, codeNode) { + return { + type: 'mdxJsxFlowElement', + name: 'ConfigFormatTab', + attributes: [{ type: 'mdxJsxAttribute', name: 'label', value: label }], + children: [codeNode], + data: { _mdxExplicitJsx: true }, + }; +} + +function createCodeNode(lang, value, meta) { + return { type: 'code', lang, meta, value }; +} + +function rewriteMetaForFormat(meta, replacementName, newHighlightStr) { + if (!meta) { + return newHighlightStr || undefined; } - const inner = ' '.repeat(indent + 1); - const close = ' '.repeat(indent); - const entries = keys.map((key) => { - const formattedKey = isLuauIdentifier(key) ? key : `[${JSON.stringify(key)}]`; - const formattedValue = formatLuauValue(object[key], indent + 1); - return `${inner}${formattedKey} = ${formattedValue}`; - }); - return `{\n${entries.join(',\n')},\n${close}}`; + let result = meta; + const filename = extractFilenameFromMeta(meta); + if (filename) { + const newFilename = filename.replace( + /lithos\.(ya?ml|json|luau|lua)$/i, + replacementName + ); + result = result.replace( + /(filename|title)="[^"]+"/, + `filename="${newFilename}"` + ); + } + + if (/\{[^}]+\}/.test(result)) { + result = result.replace( + /\s*\{[^}]+\}/, + newHighlightStr ? ` ${newHighlightStr}` : '' + ); + } else if (newHighlightStr) { + result = `${result.trim()} ${newHighlightStr}`; + } + + result = result.trim(); + return result.length > 0 ? result : undefined; } -function isLuauIdentifier(key) { - return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && !LUAU_RESERVED.has(key); +function extractFilenameFromMeta(meta) { + const match = meta?.match(/(?:filename|title)="([^"]+)"/); + return match?.[1]; } module.exports = { createTransformLithosConfigExamples, -}; \ No newline at end of file +}; From ea579feb30483312c8df207dd99596281f8db4f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 22:57:29 -0600 Subject: [PATCH 07/10] docs(luau): require explicit lithos.yml filename to render format tabs Drops the content-based fallback in shouldTransform so partial YAML snippets (without a `filename=` meta) stay plain YAML instead of being converted into JSON/Luau tabs. Fixes the environments page where small illustrative snippets were rendering as full config tabs out of step with the surrounding prose. --- .../transform-lithos-config-examples.ts | 39 ++++++------------- .../transform-lithos-config-examples.js | 37 ++++++------------ 2 files changed, 22 insertions(+), 54 deletions(-) diff --git a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts index f324dae..bc972df 100644 --- a/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts +++ b/docs/packages/lib/src/remark-plugins/transform-lithos-config-examples.ts @@ -1,5 +1,4 @@ import { - parse as parseYaml, parseDocument, isMap, isSeq, @@ -13,14 +12,6 @@ export interface TransformLithosConfigExamplesOptions { mode?: 'all' | 'project'; } -const PROJECT_CONFIG_KEYS = new Set([ - 'owner', - 'payments', - 'environments', - 'target', - 'state', -]); - // Reserved words that cannot be used as bare identifiers in Lua / Luau. const LUAU_RESERVED = new Set([ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', @@ -108,30 +99,22 @@ function shouldTransform(codeNode: CodeNode, mode: 'all' | 'project') { return true; } + // In `project` mode we only transform YAML blocks that explicitly declare a + // `lithos.yml` (or `lithos.yaml`) filename. Snippets without a filename are + // treated as illustrative fragments and left untouched so JSON / Luau tabs + // don't show partial config shapes that the surrounding prose isn't talking + // about. const filename = extractFilenameFromMeta(codeNode.meta); - if (filename) { - const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); - if (isExcludedFilename(normalizedFilename)) { - return false; - } - - if (isLithosConfigFilename(normalizedFilename)) { - return true; - } + if (!filename) { + return false; } - try { - const parsed = parseYaml(codeNode.value); - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { - return false; - } - - return Object.keys(parsed as Record).some((key) => - PROJECT_CONFIG_KEYS.has(key) - ); - } catch { + const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); + if (isExcludedFilename(normalizedFilename)) { return false; } + + return isLithosConfigFilename(normalizedFilename); } function isExcludedFilename(filename: string) { diff --git a/docs/site/remark-plugins/transform-lithos-config-examples.js b/docs/site/remark-plugins/transform-lithos-config-examples.js index d63d27f..9fe8a5f 100644 --- a/docs/site/remark-plugins/transform-lithos-config-examples.js +++ b/docs/site/remark-plugins/transform-lithos-config-examples.js @@ -1,5 +1,4 @@ const { - parse: parseYaml, parseDocument, isMap, isSeq, @@ -8,14 +7,6 @@ const { } = require('yaml'); const { visit } = require('unist-util-visit'); -const PROJECT_CONFIG_KEYS = new Set([ - 'owner', - 'payments', - 'environments', - 'target', - 'state', -]); - // Reserved words that cannot be used as bare identifiers in Lua / Luau. const LUAU_RESERVED = new Set([ 'and', 'break', 'do', 'else', 'elseif', 'end', 'false', 'for', @@ -76,28 +67,22 @@ function shouldTransform(codeNode, mode) { return true; } + // In `project` mode we only transform YAML blocks that explicitly declare a + // `lithos.yml` (or `lithos.yaml`) filename. Snippets without a filename are + // treated as illustrative fragments and left untouched so JSON / Luau tabs + // don't show partial config shapes that the surrounding prose isn't talking + // about. const filename = extractFilenameFromMeta(codeNode.meta); - if (filename) { - const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); - if (isExcludedFilename(normalizedFilename)) { - return false; - } - - if (isLithosConfigFilename(normalizedFilename)) { - return true; - } + if (!filename) { + return false; } - try { - const parsed = parseYaml(codeNode.value); - if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { - return false; - } - - return Object.keys(parsed).some((key) => PROJECT_CONFIG_KEYS.has(key)); - } catch { + const normalizedFilename = filename.toLowerCase().replace(/\\/g, '/'); + if (isExcludedFilename(normalizedFilename)) { return false; } + + return isLithosConfigFilename(normalizedFilename); } function isExcludedFilename(filename) { From a51fbfafd473ceed4c1bcab9a2ff8966ccad6edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Fri, 22 May 2026 22:59:16 -0600 Subject: [PATCH 08/10] docs: drop full layout on configuration reference page The `layout: 'full'` setting in the reference page meta also strips the Nextra breadcrumb, leaving its top area visibly inconsistent with the rest of the docs. Switch to the default layout while keeping the TOC hidden so the breadcrumb returns and the page header matches every other docs page. --- docs/site/pages/docs/configuration/_meta.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/site/pages/docs/configuration/_meta.json b/docs/site/pages/docs/configuration/_meta.json index 2e49a54..0d15638 100644 --- a/docs/site/pages/docs/configuration/_meta.json +++ b/docs/site/pages/docs/configuration/_meta.json @@ -1,6 +1,6 @@ { "reference": { "title": "Reference", - "theme": { "toc": false, "layout": "full" } + "theme": { "toc": false } } } From 93758b4a9a86a88d79ee116884c7322af6126111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Sat, 23 May 2026 10:38:17 -0600 Subject: [PATCH 09/10] chore(release): lithos 0.4.0-beta.2 Bumps the lithos crate, foreman example, and README install snippet from 0.4.0 to 0.4.0-beta.2, and records a changelog entry covering Luau project configs (Lune-based evaluator + on* hooks) and the new YAML/JSON/Luau docs tabs. --- Cargo.lock | 2 +- README.md | 2 +- examples/foreman.toml | 2 +- src/lithos/CHANGELOG.md | 7 +++++++ src/lithos/Cargo.toml | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afd868e..4657eaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1957,7 +1957,7 @@ checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "lithos" -version = "0.4.0" +version = "0.4.0-beta.2" dependencies = [ "assert_cmd", "clap 2.34.0", diff --git a/README.md b/README.md index 39bccd2..9a4292b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Releases are published from [`siriuslatte/lithos`](https://github.com/siriuslatt ```toml # foreman.toml [tools] -lithos = { source = "siriuslatte/lithos", version = "0.4.0" } +lithos = { source = "siriuslatte/lithos", version = "0.4.0-beta.2" } ``` ### Manual diff --git a/examples/foreman.toml b/examples/foreman.toml index 60e3ad8..9ea67e9 100644 --- a/examples/foreman.toml +++ b/examples/foreman.toml @@ -1,4 +1,4 @@ [tools] # `lithos` is the CLI binary. Releases are published from # https://github.com/siriuslatte/lithos/releases. -lithos = { source = "siriuslatte/lithos", version = "0.4.0" } +lithos = { source = "siriuslatte/lithos", version = "0.4.0-beta.2" } diff --git a/src/lithos/CHANGELOG.md b/src/lithos/CHANGELOG.md index 898bd98..cdc1b88 100644 --- a/src/lithos/CHANGELOG.md +++ b/src/lithos/CHANGELOG.md @@ -1,5 +1,12 @@ # lithos +## 0.4.0-beta.2 + +### Minor Changes + +- Luau / Lua project configs are now supported alongside YAML and JSON via a bundled Lune evaluator, with optional `on*` lifecycle hooks (starting with `onConfigLoaded`). +- Docs site now renders every `lithos.yml` example as YAML / JSON / Luau tabs and translates line-highlight metadata across all three formats. + ## 0.4.0 ### Minor Changes diff --git a/src/lithos/Cargo.toml b/src/lithos/Cargo.toml index fd5c63a..17f7596 100644 --- a/src/lithos/Cargo.toml +++ b/src/lithos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lithos" -version = "0.4.0" +version = "0.4.0-beta.2" edition = "2021" description = "Infra-as-code and deployment tool for Roblox" license = "MIT" From c2e0938e4a878cdd8dc1b8aed9bb129dd0525f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20S=E2=94=9C=C3=ADnchez?= Date: Sat, 23 May 2026 11:17:07 -0600 Subject: [PATCH 10/10] chore(release): mirror 0.4.0-beta.2 across docs, README pointers, and templates Updates the migrating-from-mantle Foreman snippet, the home page version badge, and the bug-report issue template placeholder to match the bumped crate version. --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- docs/site/components/home-landing.tsx | 2 +- docs/site/pages/docs/guides/migrating-from-mantle.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 53e6ae6..335e9e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -150,7 +150,7 @@ body: attributes: label: Lithos version description: Output of `lithos --version` if available. - placeholder: lithos 0.4.0 + placeholder: lithos 0.4.0-beta.2 validations: required: true diff --git a/docs/site/components/home-landing.tsx b/docs/site/components/home-landing.tsx index 8b48d7b..196e44c 100644 --- a/docs/site/components/home-landing.tsx +++ b/docs/site/components/home-landing.tsx @@ -122,7 +122,7 @@ export function HomeLanding() {
- v0.4.0 + v0.4.0-beta.2

Lithos

diff --git a/docs/site/pages/docs/guides/migrating-from-mantle.mdx b/docs/site/pages/docs/guides/migrating-from-mantle.mdx index c3e2325..f8cbcda 100644 --- a/docs/site/pages/docs/guides/migrating-from-mantle.mdx +++ b/docs/site/pages/docs/guides/migrating-from-mantle.mdx @@ -62,7 +62,7 @@ Update `foreman.toml`: ```toml filename="foreman.toml" [tools] - mantle = { source = "blake-mealey/mantle", version = "0.11" } -+ lithos = { source = "siriuslatte/lithos", version = "0.4.0" } ++ lithos = { source = "siriuslatte/lithos", version = "0.4.0-beta.2" } ``` Run `foreman install`, then update scripts and CI commands to call `lithos`.