Compare commits

...
23 Commits
Author SHA1 Message Date
Hare 5a05da6fe4 Use non-underscore Astro asset directory 2026-07-10 04:36:22 +09:00
Hare aa0f464320 Bump CodeMirror package to 0.1.5 2026-07-10 04:30:38 +09:00
Hare fc1f3e0b25 Improve JSR package metadata 2026-07-10 04:19:00 +09:00
Hare 8eac5c5cb4 Bundle language tools with CodeMirror package 2026-07-09 21:25:04 +09:00
Hare 59cb8f31e4 Simplify package link labels 2026-07-09 20:50:09 +09:00
Hare 4eee2cd2c9 Make package links flex layout 2026-07-09 20:45:16 +09:00
Hare 44b6041e60 Compact package links on landing page 2026-07-09 20:36:57 +09:00
Hare a95afe0463 Align playground control heights 2026-07-09 19:36:15 +09:00
Hare 43ab6244ef Reduce button vertical padding 2026-07-09 19:34:33 +09:00
Hare 1d4503b015 Simplify playground action separator 2026-07-09 19:33:48 +09:00
Hare 250e29e00c Remove playground helper copy 2026-07-09 19:29:22 +09:00
Hare 1080081cec Reset state before loading playground examples 2026-07-09 19:27:19 +09:00
Hare 43f692e75c Add playground entry selector 2026-07-09 19:21:12 +09:00
Hare 5b26d071ca Run playground examples from entrypoint 2026-07-09 19:12:18 +09:00
Hare a653f0b7c6 Nest repeated playground fields 2026-07-09 19:06:28 +09:00
Hare 384d17b539 Replace playground examples 2026-07-09 19:02:11 +09:00
Hare 09a84264ab Format playground examples 2026-07-09 18:54:29 +09:00
Hare 0a8c261545 Load playground wasm assets through Vite 2026-07-09 18:50:26 +09:00
Hare a8a46998a8 Make build:wasm build all wasm packages 2026-07-09 18:46:24 +09:00
Hare e4a9f80617 Dedupe CodeMirror dependencies 2026-07-09 18:43:09 +09:00
Hare 0e2a7b35d2 Add language tools package 2026-07-09 18:34:41 +09:00
Hare ac99c4cb4e Revert "Add Decodal formatter"
This reverts commit 22f6bc1ab9.
2026-07-09 17:46:24 +09:00
Hare 22f6bc1ab9 Add Decodal formatter 2026-07-09 17:39:32 +09:00
34 changed files with 2122 additions and 247 deletions
Generated
+51
View File
@@ -17,6 +17,16 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.2.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -48,6 +58,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "decodal-language-tools"
version = "0.1.2"
dependencies = [
"decodal",
"serde_json",
"tree-sitter",
"tree-sitter-decodal",
"wasm-bindgen",
]
[[package]] [[package]]
name = "decodal-wasm" name = "decodal-wasm"
version = "0.1.2" version = "0.1.2"
@@ -57,6 +78,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -170,6 +197,12 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -181,6 +214,24 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tree-sitter"
version = "0.22.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7cc499ceadd4dcdf7ec6d4cbc34ece92c3fa07821e287aedecd4416c516dca"
dependencies = [
"cc",
"regex",
]
[[package]]
name = "tree-sitter-decodal"
version = "0.0.1"
dependencies = [
"cc",
"tree-sitter",
]
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
+1
View File
@@ -4,6 +4,7 @@ members = [
"crates/decodal-cli", "crates/decodal-cli",
"crates/decodal-wasm", "crates/decodal-wasm",
"crates/decodal-derive", "crates/decodal-derive",
"crates/decodal-language-tools",
] ]
resolver = "2" resolver = "2"
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "decodal-language-tools"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme.workspace = true
description = "Source-level language tooling for Decodal."
keywords = ["decodal", "formatter", "lsp", "tree-sitter"]
categories = ["development-tools", "text-processing"]
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
decodal = { version = "0.1.2", path = "../decodal-core" }
serde_json.workspace = true
tree-sitter = "0.22.6"
tree-sitter-decodal = { path = "../../editors/tree-sitter-decodal" }
wasm-bindgen.workspace = true
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
+851
View File
@@ -0,0 +1,851 @@
use std::{error::Error, fmt};
#[cfg(not(target_arch = "wasm32"))]
use tree_sitter::{Node, Parser, TreeCursor};
use wasm_bindgen::prelude::*;
const INDENT: usize = 4;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatError {
message: String,
}
impl FormatError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for FormatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for FormatError {}
pub fn format_source(source: &str) -> Result<String, FormatError> {
decodal::parse_source(source)
.map_err(|diagnostic| FormatError::new(format!("parse error: {}", diagnostic.message)))?;
format_source_impl(source)
}
#[cfg(not(target_arch = "wasm32"))]
fn format_source_impl(source: &str) -> Result<String, FormatError> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_decodal::language())
.map_err(|error| {
FormatError::new(format!("failed to load tree-sitter grammar: {error}"))
})?;
let tree = parser
.parse(source, None)
.ok_or_else(|| FormatError::new("tree-sitter failed to parse source"))?;
let root = tree.root_node();
if root.has_error() {
return Err(FormatError::new(
"parse error: source contains tree-sitter errors",
));
}
let mut formatter = Formatter::new(source);
let mut out = formatter.format_source_file(root);
if !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
#[wasm_bindgen(js_name = formatSource)]
pub fn format_source_json(source: &str) -> String {
match format_source(source) {
Ok(formatted) => serde_json::json!({ "ok": true, "source": formatted }).to_string(),
Err(error) => serde_json::json!({ "ok": false, "error": error.message() }).to_string(),
}
}
#[cfg(target_arch = "wasm32")]
fn format_source_impl(source: &str) -> Result<String, FormatError> {
Ok(LexicalFormatter::new(source).format())
}
#[cfg(target_arch = "wasm32")]
struct LexicalFormatter<'a> {
chars: core::iter::Peekable<core::str::Chars<'a>>,
out: String,
indent: usize,
line_start: bool,
}
#[cfg(target_arch = "wasm32")]
impl<'a> LexicalFormatter<'a> {
fn new(source: &'a str) -> Self {
Self {
chars: source.chars().peekable(),
out: String::new(),
indent: 0,
line_start: true,
}
}
fn format(mut self) -> String {
while let Some(ch) = self.chars.next() {
match ch {
' ' | '\t' | '\r' | '\n' => self.consume_whitespace(ch),
'#' => self.write_comment(),
'"' => self.write_string(),
'{' => self.open_block('{'),
'[' => self.open_block('['),
'}' => self.close_block('}'),
']' => self.close_block(']'),
';' => self.end_statement(),
',' => self.write_comma(),
'.' => self.write_compact('.'),
':' => self.write_spaced_operator(":"),
'(' => self.write_compact('('),
')' => self.write_compact(')'),
'+' | '-' | '*' | '/' | '=' | '!' | '&' | '|' | '>' | '<' => {
self.write_operator(ch)
}
_ => self.write_word(ch),
}
}
self.trim_trailing_spaces();
if !self.out.ends_with('\n') {
self.out.push('\n');
}
self.out
}
fn consume_whitespace(&mut self, ch: char) {
if ch == '\n' && self.out.ends_with('\n') && !self.out.ends_with("\n\n") {
self.out.push('\n');
self.line_start = true;
}
}
fn write_comment(&mut self) {
if self.line_start {
self.write_indent();
} else {
self.ensure_space();
}
self.out.push('#');
for ch in self.chars.by_ref() {
if ch == '\n' {
break;
}
self.out.push(ch);
}
self.trim_trailing_spaces();
self.out.push('\n');
self.line_start = true;
}
fn write_string(&mut self) {
self.write_indent_if_needed();
self.ensure_word_boundary();
self.out.push('"');
let mut escaped = false;
for ch in self.chars.by_ref() {
self.out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
break;
}
}
}
fn open_block(&mut self, ch: char) {
self.trim_trailing_spaces();
self.out.push(' ');
self.out.push(ch);
self.out.push('\n');
self.indent += INDENT;
self.line_start = true;
}
fn close_block(&mut self, ch: char) {
self.trim_trailing_spaces();
if !self.out.ends_with('\n') {
self.out.push('\n');
}
self.indent = self.indent.saturating_sub(INDENT);
self.write_indent();
self.out.push(ch);
self.line_start = false;
}
fn end_statement(&mut self) {
self.trim_trailing_spaces();
self.out.push(';');
self.out.push('\n');
self.line_start = true;
}
fn write_comma(&mut self) {
self.trim_trailing_spaces();
self.out.push(',');
self.out.push(' ');
self.line_start = false;
}
fn write_operator(&mut self, ch: char) {
let mut op = String::new();
op.push(ch);
if let Some(next) = self.chars.peek().copied() {
let two_char = matches!(
(ch, next),
('=', '=')
| ('!', '=')
| ('>', '=')
| ('<', '=')
| ('+', '+')
| ('/', '/')
| ('&', '&')
| ('|', '|')
| ('=', '>')
);
if two_char {
op.push(next);
self.chars.next();
}
}
self.write_spaced_operator(&op);
}
fn write_spaced_operator(&mut self, op: &str) {
self.trim_trailing_spaces();
self.ensure_space();
self.out.push_str(op);
self.out.push(' ');
self.line_start = false;
}
fn write_compact(&mut self, ch: char) {
self.trim_trailing_spaces();
self.write_indent_if_needed();
self.out.push(ch);
self.line_start = false;
}
fn write_word(&mut self, first: char) {
self.write_indent_if_needed();
self.ensure_word_boundary();
self.out.push(first);
while let Some(next) = self.chars.peek().copied() {
if next.is_alphanumeric() || next == '_' {
self.out.push(next);
self.chars.next();
} else {
break;
}
}
self.line_start = false;
}
fn write_indent_if_needed(&mut self) {
if self.line_start {
self.write_indent();
self.line_start = false;
}
}
fn write_indent(&mut self) {
for _ in 0..self.indent {
self.out.push(' ');
}
}
fn ensure_word_boundary(&mut self) {
let needs_space = self
.out
.chars()
.last()
.is_some_and(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '"'));
if needs_space {
self.out.push(' ');
}
}
fn ensure_space(&mut self) {
let needs_space = self
.out
.chars()
.last()
.is_some_and(|ch| !matches!(ch, ' ' | '\n' | '.' | '(' | '['));
if needs_space {
self.out.push(' ');
}
}
fn trim_trailing_spaces(&mut self) {
while self.out.ends_with(' ') || self.out.ends_with('\t') {
self.out.pop();
}
}
}
#[cfg(not(target_arch = "wasm32"))]
struct Formatter<'a> {
source: &'a str,
}
#[cfg(not(target_arch = "wasm32"))]
impl<'a> Formatter<'a> {
fn new(source: &'a str) -> Self {
Self { source }
}
fn format_source_file(&mut self, node: Node<'a>) -> String {
let mut out = String::new();
let children = named_children(node);
self.write_statement_list(&mut out, &children, 0);
out
}
fn write_statement_list(&mut self, out: &mut String, children: &[Node<'a>], indent: usize) {
let mut pending_comments = Vec::new();
let mut pending_blank = false;
let mut previous_end = 0;
let mut previous_statement_end_row = None;
for &child in children {
let is_trailing_comment = child.kind() == "comment"
&& previous_statement_end_row == Some(child.start_position().row);
if previous_statement_end_row.is_some() && !is_trailing_comment {
if !out.ends_with('\n') {
out.push('\n');
}
previous_statement_end_row = None;
}
if previous_end > 0 && has_blank_line(self.slice(previous_end, child.start_byte())) {
pending_blank = true;
}
if child.kind() == "comment" {
if is_trailing_comment {
if !out.ends_with(' ') {
out.push(' ');
}
out.push_str(self.raw_trimmed(child));
out.push('\n');
previous_statement_end_row = None;
} else {
pending_comments.push(child);
}
previous_end = child.end_byte();
continue;
}
if pending_blank && !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
pending_blank = false;
for comment in pending_comments.drain(..) {
write_indent(out, indent);
out.push_str(self.raw_trimmed(comment));
out.push('\n');
}
write_indent(out, indent);
self.write_statement(out, child, indent);
previous_statement_end_row = Some(child.end_position().row);
previous_end = child.end_byte();
}
if previous_statement_end_row.is_some() && !out.ends_with('\n') {
out.push('\n');
}
if pending_blank && !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
for comment in pending_comments {
write_indent(out, indent);
out.push_str(self.raw_trimmed(comment));
out.push('\n');
}
}
fn write_statement(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
match node.kind() {
"field_definition" => {
self.write_field_definition(out, node, indent);
out.push(';');
}
_ => {
self.write_expr(out, node, indent, 0);
out.push(';');
}
}
}
fn write_field_definition(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
if let Some(path) = node.child_by_field_name("path") {
self.write_field_path(out, path);
} else {
out.push_str(self.raw_trimmed(node));
return;
}
out.push_str(" = ");
if let Some(value) = node.child_by_field_name("value") {
self.write_expr(out, value, indent, 0);
}
}
fn write_field_path(&mut self, out: &mut String, node: Node<'a>) {
let identifiers: Vec<_> = named_children(node)
.into_iter()
.filter(|child| child.kind() == "identifier")
.collect();
if identifiers.is_empty() {
out.push_str(self.raw_trimmed(node));
return;
}
for (index, identifier) in identifiers.into_iter().enumerate() {
if index > 0 {
out.push('.');
}
out.push_str(self.raw_trimmed(identifier));
}
}
fn write_expr(&mut self, out: &mut String, node: Node<'a>, indent: usize, parent_prec: u8) {
if expression_contains_comment(node)
&& !matches!(
node.kind(),
"object" | "array" | "let_expression" | "match_expression"
)
{
out.push_str(self.raw_trimmed(node));
return;
}
let prec = self.precedence(node);
let parenthesize = prec < parent_prec;
if parenthesize {
out.push('(');
}
match node.kind() {
"literal" => {
if let Some(child) = named_children(node).first().copied() {
out.push_str(self.raw_trimmed(child));
} else {
out.push_str(self.raw_trimmed(node));
}
}
"identifier" | "string" | "integer" | "float" | "boolean" | "regex_literal" => {
out.push_str(self.raw_trimmed(node));
}
"comparison_constraint" => self.write_comparison_constraint(out, node, indent),
"object" => self.write_object(out, node, indent),
"array" => self.write_array(out, node, indent),
"let_expression" => self.write_let(out, node, indent),
"function_expression" => self.write_function(out, node, indent),
"match_expression" => self.write_match(out, node, indent),
"import_expression" => self.write_import(out, node),
"parenthesized_expression" => {
out.push('(');
if let Some(expr) = named_children(node)
.into_iter()
.find(|child| child.kind() != "comment")
{
self.write_expr(out, expr, indent, 0);
}
out.push(')');
}
"call_expression" => self.write_call(out, node, indent),
"path_expression" => self.write_path(out, node, indent),
"unary_expression" => self.write_unary(out, node, indent),
"binary_expression" | "default_expression" => {
self.write_binary_like(out, node, indent, prec)
}
_ => out.push_str(self.raw_trimmed(node)),
}
if parenthesize {
out.push(')');
}
}
fn write_object(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
let children = named_children(node);
if children.is_empty() {
out.push_str("{}");
return;
}
out.push('{');
out.push('\n');
self.write_statement_list(out, &children, indent + INDENT);
write_indent(out, indent);
out.push('}');
}
fn write_array(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
let children = named_children(node);
if children.is_empty() {
out.push_str("[]");
return;
}
let has_comment = children.iter().any(|child| child.kind() == "comment");
let inline = !has_comment && children.iter().all(|child| is_inline_expr(*child));
if inline {
out.push('[');
for (index, child) in children.into_iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
self.write_expr(out, child, indent, 0);
}
out.push(']');
return;
}
out.push('[');
out.push('\n');
let mut pending_comments = Vec::new();
for child in children {
if child.kind() == "comment" {
pending_comments.push(child);
continue;
}
for comment in pending_comments.drain(..) {
write_indent(out, indent + INDENT);
out.push_str(self.raw_trimmed(comment));
out.push('\n');
}
write_indent(out, indent + INDENT);
self.write_expr(out, child, indent + INDENT, 0);
out.push_str(",\n");
}
for comment in pending_comments {
write_indent(out, indent + INDENT);
out.push_str(self.raw_trimmed(comment));
out.push('\n');
}
write_indent(out, indent);
out.push(']');
}
fn write_let(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
let body = node.child_by_field_name("body");
out.push_str("let");
out.push('\n');
let children: Vec<_> = named_children(node)
.into_iter()
.filter(|child| Some(*child) != body)
.collect();
self.write_statement_list(out, &children, indent + INDENT);
write_indent(out, indent);
out.push_str("in");
if let Some(body) = body {
out.push('\n');
write_indent(out, indent + INDENT);
self.write_expr(out, body, indent + INDENT, 0);
}
}
fn write_function(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
let body = node.child_by_field_name("body");
let params: Vec<_> = named_children(node)
.into_iter()
.filter(|child| child.kind() == "parameter")
.collect();
out.push('(');
for (index, param) in params.into_iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
self.write_parameter(out, param, indent);
}
out.push_str(") =>");
if let Some(body) = body {
if is_inline_expr(body) {
out.push(' ');
self.write_expr(out, body, indent, 0);
} else {
out.push('\n');
write_indent(out, indent + INDENT);
self.write_expr(out, body, indent + INDENT, 0);
}
}
}
fn write_parameter(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
if let Some(name) = node.child_by_field_name("name") {
out.push_str(self.raw_trimmed(name));
}
if let Some(constraint) = node.child_by_field_name("constraint") {
out.push_str(": ");
self.write_expr(out, constraint, indent, 0);
}
}
fn write_match(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
out.push_str("match ");
if let Some(scrutinee) = node.child_by_field_name("scrutinee") {
self.write_expr(out, scrutinee, indent, 0);
}
out.push_str(" {");
let children: Vec<_> = named_children(node)
.into_iter()
.filter(|child| child.kind() == "match_arm" || child.kind() == "comment")
.collect();
if !children.is_empty() {
out.push('\n');
self.write_match_arms(out, &children, indent + INDENT);
write_indent(out, indent);
}
out.push('}');
}
fn write_match_arms(&mut self, out: &mut String, children: &[Node<'a>], indent: usize) {
let mut pending_comments = Vec::new();
for &child in children {
if child.kind() == "comment" {
pending_comments.push(child);
continue;
}
for comment in pending_comments.drain(..) {
write_indent(out, indent);
out.push_str(self.raw_trimmed(comment));
out.push('\n');
}
write_indent(out, indent);
if let Some(pattern) = child.child_by_field_name("pattern") {
self.write_expr(out, pattern, indent, 0);
}
out.push_str(": ");
if let Some(body) = child.child_by_field_name("body") {
self.write_expr(out, body, indent, 0);
}
out.push_str(";\n");
}
for comment in pending_comments {
write_indent(out, indent);
out.push_str(self.raw_trimmed(comment));
out.push('\n');
}
}
fn write_import(&mut self, out: &mut String, node: Node<'a>) {
out.push_str("import ");
if let Some(specifier) = node.child_by_field_name("specifier") {
out.push_str(self.raw_trimmed(specifier));
}
}
fn write_call(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
let callee = node.child_by_field_name("function");
if let Some(callee) = callee {
self.write_expr(out, callee, indent, self.precedence(node));
}
out.push('(');
let args: Vec<_> = named_children(node)
.into_iter()
.filter(|child| Some(*child) != callee && child.kind() != "comment")
.collect();
for (index, arg) in args.into_iter().enumerate() {
if index > 0 {
out.push_str(", ");
}
self.write_expr(out, arg, indent, 0);
}
out.push(')');
}
fn write_path(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
if let Some(object) = node.child_by_field_name("object") {
self.write_expr(out, object, indent, self.precedence(node));
}
out.push('.');
if let Some(field) = node.child_by_field_name("field") {
out.push_str(self.raw_trimmed(field));
}
}
fn write_unary(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
if let Some(operand) = node.child_by_field_name("operand") {
let operator = self.slice(node.start_byte(), operand.start_byte()).trim();
out.push_str(operator);
self.write_expr(out, operand, indent, self.precedence(node));
} else {
out.push_str(self.raw_trimmed(node));
}
}
fn write_comparison_constraint(&mut self, out: &mut String, node: Node<'a>, indent: usize) {
if let Some(value) = node.child_by_field_name("value") {
let operator = self.slice(node.start_byte(), value.start_byte()).trim();
out.push_str(operator);
out.push(' ');
self.write_expr(out, value, indent, self.precedence(node));
} else {
out.push_str(self.raw_trimmed(node));
}
}
fn write_binary_like(&mut self, out: &mut String, node: Node<'a>, indent: usize, prec: u8) {
let left = node
.child_by_field_name("left")
.or_else(|| node.child_by_field_name("base"));
let right = node
.child_by_field_name("right")
.or_else(|| node.child_by_field_name("fallback"));
let (Some(left), Some(right)) = (left, right) else {
out.push_str(self.raw_trimmed(node));
return;
};
self.write_expr(out, left, indent, prec);
out.push(' ');
if node.kind() == "default_expression" {
out.push_str("default");
} else {
out.push_str(self.slice(left.end_byte(), right.start_byte()).trim());
}
out.push(' ');
self.write_expr(out, right, indent, prec + 1);
}
fn precedence(&self, node: Node<'a>) -> u8 {
match node.kind() {
"default_expression" => 1,
"binary_expression" => self.binary_precedence(node),
"unary_expression" | "comparison_constraint" => 17,
"call_expression" | "path_expression" => 19,
_ => 20,
}
}
fn binary_precedence(&self, node: Node<'a>) -> u8 {
let Some(left) = node.child_by_field_name("left") else {
return 10;
};
let Some(right) = node.child_by_field_name("right") else {
return 10;
};
match self.slice(left.end_byte(), right.start_byte()).trim() {
"//" => 3,
"&" => 5,
"||" => 7,
"&&" => 9,
"==" | "!=" | ">" | ">=" | "<" | "<=" => 11,
"++" => 12,
"+" | "-" => 13,
"*" | "/" => 15,
_ => 10,
}
}
fn raw_trimmed(&self, node: Node<'a>) -> &'a str {
self.slice(node.start_byte(), node.end_byte()).trim()
}
fn slice(&self, start: usize, end: usize) -> &'a str {
&self.source[start..end]
}
}
#[cfg(not(target_arch = "wasm32"))]
fn named_children<'a>(node: Node<'a>) -> Vec<Node<'a>> {
let mut cursor: TreeCursor<'a> = node.walk();
node.named_children(&mut cursor).collect()
}
#[cfg(not(target_arch = "wasm32"))]
fn expression_contains_comment(node: Node<'_>) -> bool {
if node.kind() == "comment" {
return true;
}
named_children(node)
.into_iter()
.any(expression_contains_comment)
}
#[cfg(not(target_arch = "wasm32"))]
fn is_inline_expr(node: Node<'_>) -> bool {
match node.kind() {
"literal"
| "identifier"
| "string"
| "integer"
| "float"
| "boolean"
| "regex_literal"
| "comparison_constraint"
| "import_expression" => true,
"path_expression"
| "call_expression"
| "unary_expression"
| "binary_expression"
| "default_expression"
| "parenthesized_expression" => {
!expression_contains_comment(node)
&& named_children(node).into_iter().all(is_inline_expr)
}
"array" => {
!expression_contains_comment(node)
&& named_children(node).into_iter().all(is_inline_expr)
}
_ => false,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn has_blank_line(text: &str) -> bool {
text.bytes().filter(|byte| *byte == b'\n').count() >= 2
}
#[cfg(not(target_arch = "wasm32"))]
fn write_indent(out: &mut String, indent: usize) {
for _ in 0..indent {
out.push(' ');
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_fields_and_preserves_comments() {
let source =
"Server={\n# host\nhost=String default \"localhost\"; # trailing\n\nport=Int&>0;\n};";
let formatted = format_source(source).unwrap();
assert_eq!(
formatted,
"Server = {\n # host\n host = String default \"localhost\"; # trailing\n\n port = Int & > 0;\n};\n"
);
}
#[test]
fn formats_arrays_and_match_comments() {
let source = "result=match x{# c\n_: [1,{a=2;}];};";
let formatted = format_source(source).unwrap();
assert_eq!(
formatted,
"result = match x {\n # c\n _: [\n 1,\n {\n a = 2;\n },\n ];\n};\n"
);
}
#[test]
fn wasm_export_returns_json() {
let output = format_source_json("value={a=1;};");
assert!(output.contains("\"ok\":true"));
assert!(output.contains("value = {\\n a = 1;\\n};\\n"));
}
}
+20 -2
View File
@@ -39,10 +39,25 @@ The JSR package is `@hare/decodal-wasm`.
The generated files in `packages/decodal-wasm/` are committed so the site can build without requiring every consumer to run `wasm-pack` first. The generated files in `packages/decodal-wasm/` are committed so the site can build without requiring every consumer to run `wasm-pack` first.
The WebAssembly package is for execution, not syntax highlighting. The WebAssembly package is for execution, not syntax highlighting.
## Language tools
Source-level tooling lives in the Rust language tools crate.
This component is responsible for operations that must preserve source text details such as comments and whitespace.
It is an internal crate used by the future Rust LSP and by the CodeMirror package's bundled formatter WebAssembly.
Important paths:
```text
crates/decodal-language-tools/
```
The current language tools crate exposes the formatter.
Future LSP functionality should build on the same source-level tooling layer rather than on the runtime core.
## Web editor components ## Web editor components
The Web playground editor uses CodeMirror 6 with a generated Lezer parser. The Web playground editor uses CodeMirror 6 with a generated Lezer parser.
This is the source of syntax highlighting, folding, indentation, and editor syntax tree behavior in the browser UI. This is the source of syntax highlighting, folding, indentation, editor syntax tree behavior, and the browser formatter command in the browser UI.
Important paths: Important paths:
@@ -51,6 +66,8 @@ editors/lezer-decodal/decodal.grammar
packages/decodal-codemirror/src/decodal.js packages/decodal-codemirror/src/decodal.js
packages/decodal-codemirror/src/decodal-parser.js packages/decodal-codemirror/src/decodal-parser.js
packages/decodal-codemirror/src/decodal-parser.terms.js packages/decodal-codemirror/src/decodal-parser.terms.js
packages/decodal-codemirror/src/format.js
packages/decodal-codemirror/wasm/
``` ```
The npm package is `decodal-codemirror`. The npm package is `decodal-codemirror`.
@@ -94,7 +111,8 @@ Consumers that need syntax information should use the component that matches the
- Rust execution and embedding: `decodal` - Rust execution and embedding: `decodal`
- Browser execution: `decodal-wasm` - Browser execution: `decodal-wasm`
- Web editor syntax: Lezer / CodeMirror - Web formatting and editor syntax: Lezer / CodeMirror
- Rust LSP internals: `decodal-language-tools`
- General editor syntax: Tree-sitter - General editor syntax: Tree-sitter
This avoids having a separate token stream API whose behavior would have to be kept compatible with both runtime parsing and editor grammars. This avoids having a separate token stream API whose behavior would have to be kept compatible with both runtime parsing and editor grammars.
+11 -9
View File
@@ -24,8 +24,9 @@ cargo run -q -p decodal-cli --features regex -- examples/regex/main.dcdl
The primary crates.io package is `decodal`, which contains the embeddable library. The primary crates.io package is `decodal`, which contains the embeddable library.
`decodal-derive` provides optional derive macros for Rust struct integration and is published only when the derive crate changes. `decodal-derive` provides optional derive macros for Rust struct integration and is published only when the derive crate changes.
Workspace support crates such as `decodal-cli` and the Rust source crate `decodal-wasm` are not published to crates.io. Workspace support crates such as `decodal-cli`, the Rust source crate `decodal-wasm`, and `decodal-language-tools` are not published to crates.io.
The generated WebAssembly package under `packages/decodal-wasm/` is published to npm and JSR. The generated WebAssembly package under `packages/decodal-wasm/` is published to npm and JSR.
The CodeMirror package bundles the generated formatter WebAssembly from `decodal-language-tools`.
The project is dual licensed as `MIT OR Apache-2.0`. The project is dual licensed as `MIT OR Apache-2.0`.
```sh ```sh
@@ -58,7 +59,7 @@ site/decodal-site/
``` ```
The site imports Markdown files from `doc/manual/souce/` and renders them as mdBook-style pages. The site imports Markdown files from `doc/manual/souce/` and renders them as mdBook-style pages.
The playground loads `decodal-wasm` and evaluates DCDL entirely in the browser. The playground loads `decodal-wasm` for evaluation and `decodal-codemirror` for editor features and source formatting.
Important files: Important files:
@@ -74,10 +75,13 @@ packages/decodal-codemirror/src/decodal.js
packages/decodal-codemirror/src/decodal-parser.js packages/decodal-codemirror/src/decodal-parser.js
packages/decodal-wasm/decodal_wasm.js packages/decodal-wasm/decodal_wasm.js
packages/decodal-wasm/decodal_wasm_bg.wasm packages/decodal-wasm/decodal_wasm_bg.wasm
packages/decodal-codemirror/wasm/decodal_language_tools.js
packages/decodal-codemirror/wasm/decodal_language_tools_bg.wasm
crates/decodal-wasm/src/lib.rs crates/decodal-wasm/src/lib.rs
crates/decodal-language-tools/src/lib.rs
``` ```
Build the WebAssembly package before building the site: Build the WebAssembly packages before building the site:
```sh ```sh
cd site/decodal-site cd site/decodal-site
@@ -86,13 +90,11 @@ npm run build:wasm
npm run build npm run build
``` ```
`npm run build:wasm` writes generated files into: `npm run build:wasm` builds both generated WebAssembly packages.
`npm run build:runtime` writes generated runtime files into `packages/decodal-wasm/`.
`npm run build:tools` writes generated language tools files into `packages/decodal-codemirror/wasm/`.
```text These generated files are committed so the site can be built without requiring every consumer to regenerate the wasm packages first.
packages/decodal-wasm/
```
These generated files are committed so the site can be built without requiring every consumer to regenerate the wasm package first.
Publish the generated WebAssembly package from its package directory: Publish the generated WebAssembly package from its package directory:
+22 -2
View File
@@ -2,8 +2,8 @@
CodeMirror 6 language support for Decodal. CodeMirror 6 language support for Decodal.
This package provides the Lezer parser metadata, highlighting tags, indentation rules, and block folding used by the Decodal Web playground. This package provides the Lezer parser metadata, highlighting tags, indentation rules, block folding, and formatter command used by the Decodal Web playground.
It does not include the Decodal runtime or WebAssembly evaluator. It does not include the Decodal runtime evaluator.
Use `decodal-wasm` when you want to evaluate Decodal in a browser. Use `decodal-wasm` when you want to evaluate Decodal in a browser.
## Install ## Install
@@ -52,3 +52,23 @@ decodal({ highlight: false })
``` ```
Use this when you want the language support without the bundled highlight style. Use this when you want the language support without the bundled highlight style.
## Formatting
The formatter is exposed as a separate subpath so plain syntax highlighting does not force callers to initialize WebAssembly.
```js
import {
formatDecodal,
formatDecodalCommand,
initDecodalFormatter,
} from 'decodal-codemirror/format';
import formatterWasm from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url';
await initDecodalFormatter(formatterWasm);
const result = formatDecodal('Server={port=Int default 8080;};');
if (result.ok) console.log(result.source);
formatDecodalCommand(view);
```
+14 -2
View File
@@ -1,10 +1,14 @@
{ {
"name": "@hare/decodal-codemirror", "name": "@hare/decodal-codemirror",
"version": "0.1.2", "version": "0.1.5",
"license": "MIT", "license": "MIT",
"exports": "./src/mod.ts", "exports": {
".": "./src/mod.ts",
"./format": "./src/format.ts"
},
"imports": { "imports": {
"@codemirror/language": "npm:@codemirror/language@^6.12.4", "@codemirror/language": "npm:@codemirror/language@^6.12.4",
"@codemirror/view": "npm:@codemirror/view@^6.43.6",
"@lezer/highlight": "npm:@lezer/highlight@^1.2.3", "@lezer/highlight": "npm:@lezer/highlight@^1.2.3",
"@lezer/lr": "npm:@lezer/lr@^1.4.10" "@lezer/lr": "npm:@lezer/lr@^1.4.10"
}, },
@@ -12,12 +16,20 @@
"include": [ "include": [
"README.md", "README.md",
"src/mod.ts", "src/mod.ts",
"src/format.ts",
"src/decodal.js", "src/decodal.js",
"src/decodal.d.ts", "src/decodal.d.ts",
"src/decodal.js.d.ts", "src/decodal.js.d.ts",
"src/format.js",
"src/format.d.ts",
"src/format.js.d.ts",
"src/decodal-parser-jsr.js", "src/decodal-parser-jsr.js",
"src/decodal-parser.js", "src/decodal-parser.js",
"src/decodal-parser.terms.js", "src/decodal-parser.terms.js",
"wasm/decodal_language_tools.js",
"wasm/decodal_language_tools.d.ts",
"wasm/decodal_language_tools_bg.wasm",
"wasm/decodal_language_tools_bg.wasm.d.ts",
"package.json" "package.json"
] ]
} }
+20 -3
View File
@@ -1,15 +1,22 @@
{ {
"name": "decodal-codemirror", "name": "decodal-codemirror",
"version": "0.1.2", "version": "0.1.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "decodal-codemirror", "name": "decodal-codemirror",
"version": "0.1.2", "version": "0.1.5",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "devDependencies": {
"@codemirror/language": "^6.12.4", "@codemirror/language": "^6.12.4",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10"
},
"peerDependencies": {
"@codemirror/language": "^6.12.4",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10" "@lezer/lr": "^1.4.10"
} }
@@ -18,6 +25,7 @@
"version": "6.12.4", "version": "6.12.4",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -32,6 +40,7 @@
"version": "6.7.1", "version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
@@ -41,6 +50,7 @@
"version": "6.43.6", "version": "6.43.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codemirror/state": "^6.7.0", "@codemirror/state": "^6.7.0",
@@ -53,12 +63,14 @@
"version": "1.5.2", "version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lezer/highlight": { "node_modules/@lezer/highlight": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lezer/common": "^1.3.0" "@lezer/common": "^1.3.0"
@@ -68,6 +80,7 @@
"version": "1.4.10", "version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@lezer/common": "^1.0.0" "@lezer/common": "^1.0.0"
@@ -77,24 +90,28 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
"integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": { "node_modules/crelt": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/style-mod": { "node_modules/style-mod": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/w3c-keyname": { "node_modules/w3c-keyname": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT" "license": "MIT"
} }
} }
+20 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "decodal-codemirror", "name": "decodal-codemirror",
"version": "0.1.2", "version": "0.1.5",
"description": "CodeMirror 6 language support for Decodal.", "description": "CodeMirror 6 language support for Decodal.",
"type": "module", "type": "module",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
@@ -14,21 +14,37 @@
"types": "./src/decodal.d.ts", "types": "./src/decodal.d.ts",
"import": "./src/decodal.js" "import": "./src/decodal.js"
}, },
"./format": {
"types": "./src/format.d.ts",
"import": "./src/format.js"
},
"./parser": "./src/decodal-parser.js", "./parser": "./src/decodal-parser.js",
"./terms": "./src/decodal-parser.terms.js" "./terms": "./src/decodal-parser.terms.js",
"./wasm/decodal_language_tools_bg.wasm": "./wasm/decodal_language_tools_bg.wasm"
}, },
"types": "./src/decodal.d.ts", "types": "./src/decodal.d.ts",
"files": [ "files": [
"README.md", "README.md",
"src/" "src/",
"wasm/decodal_language_tools.js",
"wasm/decodal_language_tools.d.ts",
"wasm/decodal_language_tools_bg.wasm",
"wasm/decodal_language_tools_bg.wasm.d.ts"
], ],
"keywords": [ "keywords": [
"decodal", "decodal",
"codemirror", "codemirror",
"lezer" "lezer"
], ],
"dependencies": { "peerDependencies": {
"@codemirror/language": "^6.12.4", "@codemirror/language": "^6.12.4",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10"
},
"devDependencies": {
"@codemirror/language": "^6.12.4",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10" "@lezer/lr": "^1.4.10"
} }
+17
View File
@@ -0,0 +1,17 @@
import type { EditorView } from '@codemirror/view';
export interface FormatSuccess {
ok: true;
source: string;
}
export interface FormatFailure {
ok: false;
error: string;
}
export type FormatResult = FormatSuccess | FormatFailure;
export declare function initDecodalFormatter(moduleOrPath?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module): Promise<void>;
export declare function formatDecodal(source: string): FormatResult;
export declare function formatDecodalCommand(view: EditorView): boolean;
+28
View File
@@ -0,0 +1,28 @@
import initLanguageTools, { formatSource } from '../wasm/decodal_language_tools.js';
let initialized = false;
export async function initDecodalFormatter(moduleOrPath) {
await initLanguageTools(moduleOrPath);
initialized = true;
}
export function formatDecodal(source) {
if (!initialized) {
return { ok: false, error: 'Decodal formatter is not initialized' };
}
return JSON.parse(formatSource(source));
}
export function formatDecodalCommand(view) {
const result = formatDecodal(view.state.doc.toString());
if (!result.ok) return false;
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: result.source,
},
});
return true;
}
+17
View File
@@ -0,0 +1,17 @@
import type { EditorView } from '@codemirror/view';
export interface FormatSuccess {
ok: true;
source: string;
}
export interface FormatFailure {
ok: false;
error: string;
}
export type FormatResult = FormatSuccess | FormatFailure;
export declare function initDecodalFormatter(moduleOrPath?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module): Promise<void>;
export declare function formatDecodal(source: string): FormatResult;
export declare function formatDecodalCommand(view: EditorView): boolean;
+84
View File
@@ -0,0 +1,84 @@
/**
* Decodal source formatting helpers for CodeMirror integrations.
*
* The formatter is backed by WebAssembly generated from the internal Rust
* `decodal-language-tools` crate. Call {@link initDecodalFormatter} once before
* using {@link formatDecodal} or {@link formatDecodalCommand}.
*
* @module
*/
import type { EditorView } from 'npm:@codemirror/view@^6.43.6';
import initLanguageTools, { formatSource } from '../wasm/decodal_language_tools.js';
let initialized = false;
/** Successful formatter result. */
export interface FormatSuccess {
/** Indicates that formatting succeeded. */
ok: true;
/** The formatted Decodal source text. */
source: string;
}
/** Failed formatter result. */
export interface FormatFailure {
/** Indicates that formatting failed. */
ok: false;
/** Human-readable formatter error. */
error: string;
}
/** Result returned by {@link formatDecodal}. */
export type FormatResult = FormatSuccess | FormatFailure;
/**
* Initialize the bundled Decodal formatter WebAssembly module.
*
* Bundlers such as Vite should pass the package wasm asset URL explicitly:
*
* ```ts
* import wasmUrl from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url';
* await initDecodalFormatter(wasmUrl);
* ```
*
* @param moduleOrPath - Optional WebAssembly module, bytes, response, URL, or
* request accepted by wasm-bindgen's generated initializer.
*/
export async function initDecodalFormatter(
moduleOrPath?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module,
): Promise<void> {
await initLanguageTools(moduleOrPath);
initialized = true;
}
/**
* Format a complete Decodal source document.
*
* @param source - Source text to format.
* @returns Either formatted source text or an error message.
*/
export function formatDecodal(source: string): FormatResult {
if (!initialized) {
return { ok: false, error: 'Decodal formatter is not initialized' };
}
return JSON.parse(formatSource(source)) as FormatResult;
}
/**
* CodeMirror command that formats the whole document in-place.
*
* @param view - The editor view whose document should be formatted.
* @returns `true` when the document was formatted, otherwise `false`.
*/
export function formatDecodalCommand(view: EditorView): boolean {
const result = formatDecodal(view.state.doc.toString());
if (!result.ok) return false;
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: result.source,
},
});
return true;
}
+21
View File
@@ -1,3 +1,13 @@
/**
* CodeMirror 6 language support for Decodal.
*
* This module exports the Decodal language extension, parser metadata, and the
* bundled highlight style used by the official Decodal playground. Add
* {@link decodal} to a CodeMirror extension set to enable highlighting,
* indentation, and folding for Decodal documents.
*
* @module
*/
import { HighlightStyle, LRLanguage, LanguageSupport, foldNodeProp, indentNodeProp, syntaxHighlighting } from 'npm:@codemirror/language@^6.12.4'; import { HighlightStyle, LRLanguage, LanguageSupport, foldNodeProp, indentNodeProp, syntaxHighlighting } from 'npm:@codemirror/language@^6.12.4';
import { styleTags, tags as t } from 'npm:@lezer/highlight@^1.2.3'; import { styleTags, tags as t } from 'npm:@lezer/highlight@^1.2.3';
import { parser } from './decodal-parser-jsr.js'; import { parser } from './decodal-parser-jsr.js';
@@ -44,6 +54,7 @@ function foldDelimited(open: string, close: string) {
}; };
} }
/** Decodal LR language definition for CodeMirror 6. */
export const decodalLanguage: LRLanguage = LRLanguage.define({ export const decodalLanguage: LRLanguage = LRLanguage.define({
parser: parserWithMetadata, parser: parserWithMetadata,
languageData: { languageData: {
@@ -52,6 +63,7 @@ export const decodalLanguage: LRLanguage = LRLanguage.define({
}, },
}); });
/** Default Decodal highlight style used by the playground. */
export const decodalHighlightStyle: HighlightStyle = HighlightStyle.define([ export const decodalHighlightStyle: HighlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: '#93c5fd', fontWeight: '700' }, { tag: t.keyword, color: '#93c5fd', fontWeight: '700' },
{ tag: t.variableName, color: 'inherit' }, { tag: t.variableName, color: 'inherit' },
@@ -64,10 +76,19 @@ export const decodalHighlightStyle: HighlightStyle = HighlightStyle.define([
{ tag: [t.brace, t.squareBracket, t.paren, t.punctuation], color: '#f9a8d4' }, { tag: [t.brace, t.squareBracket, t.paren, t.punctuation], color: '#f9a8d4' },
]); ]);
/** Options for {@link decodal}. */
export interface DecodalOptions { export interface DecodalOptions {
/** Include the bundled {@link decodalHighlightStyle}. Defaults to `true`. */
highlight?: boolean; highlight?: boolean;
} }
/**
* Create CodeMirror language support for Decodal.
*
* @param options - Optional language support options.
* @returns A CodeMirror extension bundle containing the Decodal language and,
* unless disabled, the default highlight style.
*/
export function decodal(options: DecodalOptions = {}): LanguageSupport { export function decodal(options: DecodalOptions = {}): LanguageSupport {
const { highlight = true } = options; const { highlight = true } = options;
return new LanguageSupport( return new LanguageSupport(
@@ -0,0 +1 @@
# wasm-pack output is committed as part of decodal-codemirror.
@@ -0,0 +1,38 @@
/* tslint:disable */
/* eslint-disable */
export function formatSource(source: string): string;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly formatSource: (a: number, b: number) => [number, number];
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -0,0 +1,209 @@
/* @ts-self-types="./decodal_language_tools.d.ts" */
/**
* @param {string} source
* @returns {string}
*/
export function formatSource(source) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.formatSource(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./decodal_language_tools_bg.js": import0,
};
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('decodal_language_tools_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
@@ -0,0 +1,9 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const formatSource: (a: number, b: number) => [number, number];
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_start: () => void;
+1 -4
View File
@@ -26,11 +26,8 @@ import init, { evaluateProject } from 'decodal-wasm';
await init(); await init();
const result = evaluateProject(JSON.stringify({ const result = evaluateProject('main.dcdl', JSON.stringify({
entry: 'main.dcdl',
files: {
'main.dcdl': 'Server = { port = Int default 8080; };', 'main.dcdl': 'Server = { port = Int default 8080; };',
},
})); }));
console.log(JSON.parse(result)); console.log(JSON.parse(result));
+3 -2
View File
@@ -1,11 +1,12 @@
{ {
"name": "@hare/decodal-wasm", "name": "@hare/decodal-wasm",
"version": "0.1.2", "version": "0.1.3",
"license": "MIT", "license": "MIT",
"exports": "./decodal_wasm.js", "exports": "./mod.ts",
"publish": { "publish": {
"include": [ "include": [
"README.md", "README.md",
"mod.ts",
"decodal_wasm.js", "decodal_wasm.js",
"decodal_wasm.d.ts", "decodal_wasm.d.ts",
"decodal_wasm_bg.wasm", "decodal_wasm_bg.wasm",
+66
View File
@@ -0,0 +1,66 @@
/**
* Browser and JavaScript runtime bindings for the Decodal evaluator.
*
* This module wraps the wasm-bindgen output generated from the Rust
* `decodal-wasm` crate and exposes stable, documented entrypoints for JSR
* users. The exported evaluator functions return JSON strings.
*
* @module
*/
import initWasm, {
evaluate as evaluateImpl,
evaluateProject as evaluateProjectImpl,
initSync as initSyncImpl,
} from './decodal_wasm.js';
/** Input accepted by the wasm-bindgen initializer. */
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
/** Initialized Decodal WebAssembly exports. */
export interface InitOutput {
/** Linear memory exported by the WebAssembly module. */
readonly memory: WebAssembly.Memory;
readonly evaluate: (source: number, len: number) => [number, number, number, number];
readonly evaluateProject: (entry: number, entryLen: number, files: number, filesLen: number) => [number, number, number, number];
}
/**
* Initialize the Decodal WebAssembly runtime asynchronously.
*
* @param moduleOrPath - Optional WebAssembly module, bytes, response, URL, or request.
* @returns The initialized WebAssembly exports.
*/
export default async function initDecodalRuntime(moduleOrPath?: InitInput): Promise<InitOutput> {
return await initWasm(moduleOrPath) as InitOutput;
}
/**
* Initialize the Decodal WebAssembly runtime synchronously.
*
* @param module - Compiled module, bytes, or wasm-bindgen sync initialization input.
* @returns The initialized WebAssembly exports.
*/
export function initSync(module: WebAssembly.Module | BufferSource): InitOutput {
return initSyncImpl(module) as InitOutput;
}
/**
* Evaluate and materialize a single Decodal source string.
*
* @param source - Decodal source text.
* @returns A JSON string containing either `{ ok: true, output }` or `{ ok: false, error }`.
*/
export function evaluate(source: string): string {
return evaluateImpl(source);
}
/**
* Evaluate and materialize a virtual multi-file Decodal project.
*
* @param entry - Entry point file path to materialize.
* @param filesJson - JSON object mapping virtual file paths to source strings.
* @returns A JSON string containing either `{ ok: true, output }` or `{ ok: false, error }`.
*/
export function evaluateProject(entry: string, filesJson: string): string {
return evaluateProjectImpl(entry, filesJson);
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "decodal-wasm", "name": "decodal-wasm",
"type": "module", "type": "module",
"description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.", "description": "WebAssembly wrapper for evaluating Decodal in browser playgrounds.",
"version": "0.1.2", "version": "0.1.3",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"repository": { "repository": {
"type": "git", "type": "git",
+10
View File
@@ -2,8 +2,18 @@ import { defineConfig } from 'astro/config';
export default defineConfig({ export default defineConfig({
output: 'static', output: 'static',
build: {
assets: 'assets',
},
vite: { vite: {
resolve: { resolve: {
dedupe: [
'@codemirror/language',
'@codemirror/state',
'@codemirror/view',
'@lezer/highlight',
'@lezer/lr',
],
preserveSymlinks: true, preserveSymlinks: true,
}, },
}, },
+10 -3
View File
@@ -25,16 +25,23 @@
} }
}, },
"../../packages/decodal-codemirror": { "../../packages/decodal-codemirror": {
"version": "0.1.2", "version": "0.1.5",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "devDependencies": {
"@codemirror/language": "^6.12.4", "@codemirror/language": "^6.12.4",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10"
},
"peerDependencies": {
"@codemirror/language": "^6.12.4",
"@codemirror/view": "^6.43.6",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.10" "@lezer/lr": "^1.4.10"
} }
}, },
"../../packages/decodal-wasm": { "../../packages/decodal-wasm": {
"version": "0.1.2", "version": "0.1.3",
"license": "MIT OR Apache-2.0" "license": "MIT OR Apache-2.0"
}, },
"node_modules/@astrojs/check": { "node_modules/@astrojs/check": {
+4 -1
View File
@@ -7,7 +7,10 @@
"dev": "astro dev --host 0.0.0.0", "dev": "astro dev --host 0.0.0.0",
"build": "astro build", "build": "astro build",
"preview": "astro preview --host 0.0.0.0", "preview": "astro preview --host 0.0.0.0",
"build:wasm": "wasm-pack build ../../crates/decodal-wasm --target web --out-dir ../../packages/decodal-wasm --release && node scripts/prepare-wasm-package.mjs", "build:runtime": "wasm-pack build ../../crates/decodal-wasm --target web --out-dir ../../packages/decodal-wasm --release && node scripts/prepare-wasm-package.mjs",
"build:tools": "wasm-pack build ../../crates/decodal-language-tools --target web --out-dir ../../packages/decodal-codemirror/wasm --release && node scripts/prepare-codemirror-wasm.mjs",
"build:wasm": "npm run build:runtime && npm run build:tools",
"build:packages": "npm run build:wasm",
"deploy": "npm run build && node scripts/deploy-pages.mjs", "deploy": "npm run build && node scripts/deploy-pages.mjs",
"deploy:wasm": "npm run build:wasm && npm run deploy" "deploy:wasm": "npm run build:wasm && npm run deploy"
}, },
@@ -0,0 +1,12 @@
import { rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const wasmDir = resolve(import.meta.dirname, '../../../packages/decodal-codemirror/wasm');
rmSync(resolve(wasmDir, 'README.md'), { force: true });
rmSync(resolve(wasmDir, 'package.json'), { force: true });
writeFileSync(
resolve(wasmDir, '.gitignore'),
'# wasm-pack output is committed as part of decodal-codemirror.\n',
);
@@ -1,4 +1,4 @@
import { writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
const packageDir = resolve(import.meta.dirname, '../../../packages/decodal-wasm'); const packageDir = resolve(import.meta.dirname, '../../../packages/decodal-wasm');
@@ -8,6 +8,11 @@ writeFileSync(
'# wasm-pack output is committed for the browser runtime package.\n', '# wasm-pack output is committed for the browser runtime package.\n',
); );
const packageJsonPath = resolve(packageDir, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
packageJson.version = '0.1.3';
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
writeFileSync( writeFileSync(
resolve(packageDir, 'README.md'), resolve(packageDir, 'README.md'),
`# decodal-wasm `# decodal-wasm
@@ -38,11 +43,8 @@ import init, { evaluateProject } from 'decodal-wasm';
await init(); await init();
const result = evaluateProject(JSON.stringify({ const result = evaluateProject('main.dcdl', JSON.stringify({
entry: 'main.dcdl',
files: {
'main.dcdl': 'Server = { port = Int default 8080; };', 'main.dcdl': 'Server = { port = Int default 8080; };',
},
})); }));
console.log(JSON.parse(result)); console.log(JSON.parse(result));
@@ -51,3 +53,27 @@ console.log(JSON.parse(result));
The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures. The exported functions return JSON strings so callers can handle diagnostics and successful results without depending on Rust data structures.
`, `,
); );
writeFileSync(
resolve(packageDir, 'jsr.json'),
JSON.stringify(
{
name: '@hare/decodal-wasm',
version: '0.1.3',
license: 'MIT',
exports: './mod.ts',
publish: {
include: [
'README.md',
'mod.ts',
'decodal_wasm.js',
'decodal_wasm.d.ts',
'decodal_wasm_bg.wasm',
'package.json',
],
},
},
null,
2,
) + '\n',
);
+7 -21
View File
@@ -27,28 +27,14 @@ Production = Server & {
<section class="home-packages" aria-label="Official packages"> <section class="home-packages" aria-label="Official packages">
<h2>Official packages</h2> <h2>Official packages</h2>
<div class="package-links"> <a class="primary-package" href="https://crates.io/crates/decodal">
<a href="https://crates.io/crates/decodal">
<span>Rust crate</span> <span>Rust crate</span>
<strong>crates.io/crates/decodal</strong> <strong>crates.io/crates/decodal</strong>
</a> </a>
<a href="https://www.npmjs.com/package/decodal-wasm"> <ul class="package-list">
<span>npm package</span> <li>decodal-wasm on <a href="https://jsr.io/@hare/decodal-wasm@0.1.3">jsr</a> / <a href="https://www.npmjs.com/package/decodal-wasm">npm</a></li>
<strong>npmjs.com/package/decodal-wasm</strong> <li>decodal-codemirror on <a href="https://jsr.io/@hare/decodal-codemirror@0.1.5">jsr</a> / <a href="https://www.npmjs.com/package/decodal-codemirror">npm</a></li>
</a> </ul>
<a href="https://jsr.io/@hare/decodal-wasm@0.1.2">
<span>JSR package</span>
<strong>jsr.io/@hare/decodal-wasm</strong>
</a>
<a href="https://www.npmjs.com/package/decodal-codemirror">
<span>CodeMirror package</span>
<strong>npmjs.com/package/decodal-codemirror</strong>
</a>
<a href="https://jsr.io/@hare/decodal-codemirror@0.1.2">
<span>CodeMirror on JSR</span>
<strong>jsr.io/@hare/decodal-codemirror</strong>
</a>
</div>
</section> </section>
<section class="home-grid" aria-label="Decodal overview"> <section class="home-grid" aria-label="Decodal overview">
@@ -62,8 +48,8 @@ Production = Server & {
<article class="home-card"> <article class="home-card">
<h2>Where it runs</h2> <h2>Where it runs</h2>
<p> <p>
Use the Rust crate for runtime embedding, the WASM package in browsers, CodeMirror/Lezer for the Web editor, Use the Rust crate for runtime embedding, the WASM package in browsers, CodeMirror/Lezer for Web editing
and Tree-sitter for general editor integration. and formatting, and Tree-sitter for general editor integration.
</p> </p>
</article> </article>
<article class="home-card"> <article class="home-card">
+11 -2
View File
@@ -6,17 +6,26 @@ import ManualLayout from '../layouts/ManualLayout.astro';
<div class="playground-header"> <div class="playground-header">
<div> <div>
<h1>Playground</h1> <h1>Playground</h1>
<p>Virtual files are evaluated in the browser through WebAssembly. Use import paths such as <code>./schemas/service.dcdl</code>.</p>
</div> </div>
<div class="playground-actions"> <div class="playground-actions">
<div class="action-group example-actions">
<label class="example-picker"> <label class="example-picker">
<span>Example</span> <span>Example</span>
<select id="example-select" aria-label="Playground example"></select> <select id="example-select" aria-label="Playground example"></select>
</label> </label>
<button id="load-example" type="button">Load</button> <button id="load-example" type="button">Load</button>
<p id="status" class="status">Loading WASM...</p> </div>
<span class="action-separator" aria-hidden="true">|</span>
<div class="action-group run-actions">
<button id="format" disabled>Format</button>
<label class="entry-picker">
<span>Entry</span>
<select id="entry-select" aria-label="Entry point file"></select>
</label>
<button id="run" disabled>Run</button> <button id="run" disabled>Run</button>
</div> </div>
<p id="status" class="status">Loading WASM...</p>
</div>
</div> </div>
<div class="playground-shell"> <div class="playground-shell">
@@ -1,173 +1,392 @@
export const playgroundExamples = [ export const playgroundExamples = [
{ {
id: 'service-deployment', id: 'product-api-production',
title: 'Service deployment', title: 'Production API deployment',
activePath: 'main.dcdl', activePath: 'main.dcdl',
entryPath: 'main.dcdl',
files: { files: {
'main.dcdl': `let 'main.dcdl': `let
schema = import "./schemas/service.dcdl"; schema = import "./schemas/service.dcdl";
prod = import "./env/prod.dcdl"; env = import "./env/production.dcdl";
shared = import "./shared/tags.dcdl"; org = import "./org/platform.dcdl";
release = import "./release.dcdl";
in in
schema.Service & prod.ServicePatch & { schema.Service & env.Production & {
name = "api"; name = "checkout-api";
image = "registry.example.com/api:2026-06-24"; image = release.image;
port = 9000 + 443; owner = org.teams.checkout;
public = prod.is_public && 9000 + 443 > 9000; port = 8443;
tags = shared.common ++ ["api", "edge"]; replicas = env.capacity.base_replicas + 2;
resources = {
cpu_milli = env.capacity.cpu_milli;
memory_mb = env.capacity.memory_mb;
};
autoscaling = {
enabled = true;
min = env.capacity.base_replicas;
max = env.capacity.base_replicas * 4;
};
rollout = {
strategy = match release.risk {
"high": "canary";
"medium": "rolling";
_: "all-at-once";
};
canary_percent = match release.risk {
"high": 10;
_: 100;
};
};
ingress.hosts = env.domains.public ++ ["checkout.internal.example.com"];
labels = org.common_labels ++ ["service:checkout", "tier:edge"];
alerts.pager = org.pagers.checkout;
} }
`, `,
'schemas/service.dcdl': `Service = { 'schemas/service.dcdl': `Service = {
name = String; name = String;
image = String; image = String;
port = Int & > 443 default 8443; owner = String;
port = Int & >= 1024 & <= 65535 default 8080;
replicas = Int & > 0 default 2; replicas = Int & > 0 default 2;
public = Bool default false; resources = {
feature.metrics = Bool default true; cpu_milli = Int & >= 100 default 500;
feature.tracing = Bool default false; memory_mb = Int & >= 128 default 512;
};
autoscaling = {
enabled = Bool default false;
min = Int & > 0 default 2;
max = Int & > 0 default 4;
};
rollout = {
strategy = String default "rolling";
canary_percent = Int & >= 0 & <= 100 default 100;
};
observability = {
metrics = Bool default true;
tracing = Bool default true;
logs = Bool default true;
};
network.public = Bool default false;
}; };
`, `,
'env/prod.dcdl': `is_public = true; 'env/production.dcdl': `capacity = {
base_replicas = 6;
cpu_milli = 2000;
memory_mb = 4096;
};
ServicePatch = { domains.public = ["checkout.example.com", "api.example.com"];
replicas = 3;
feature.tracing = true; Production = {
network.public = true;
observability = {
tracing = true;
logs = true;
};
}; };
`, `,
'shared/tags.dcdl': `common = ["decodal", "prod"]; 'org/platform.dcdl': `teams = {
checkout = "team:checkout-platform";
platform = "team:core-platform";
};
pagers = {
checkout = "pagerduty:checkout-primary";
};
common_labels = ["managed-by:decodal", "environment:production"];
`,
'release.dcdl': `version = "2026.06.15";
risk = "high";
image = "registry.example.com/checkout-api:2026.06.15";
`, `,
}, },
}, },
{ {
id: 'access-policy', id: 'tenant-enterprise-plan',
title: 'Access policy', title: 'Enterprise tenant contract',
activePath: 'main.dcdl', activePath: 'main.dcdl',
entryPath: 'main.dcdl',
files: { files: {
'main.dcdl': `let 'main.dcdl': `let
policy = import "./policy/base.dcdl"; schema = import "./schemas/tenant.dcdl";
teams = import "./policy/teams.dcdl"; plans = import "./plans.dcdl";
env = "prod"; region = import "./regions/eu.dcdl";
customer = import "./customers/acme.dcdl";
in in
{ schema.Tenant & (plans.Enterprise // region.Europe // customer.Acme // {
service = "payments"; slug = "acme";
readers = policy.defaultReaders ++ teams.observability; display_name = "Acme Manufacturing";
writers = policy.defaultWriters ++ teams.payments; limits = {
admins = match env { users = customer.seats + 25;
"prod": policy.breakglassAdmins; projects = 200;
_: teams.platform; storage_gb = 2048;
}; };
audit.required = env == "prod"; features = {
audit.retention_days = match env { audit_log = true;
"prod": 365; sso = true;
_: 30; data_residency = true;
}; };
} compliance.retention_days = match customer.contract_tier {
"regulated": 2555;
_: 365;
};
notifications.channels = customer.notification_channels ++ ["security"];
})
`, `,
'policy/base.dcdl': `defaultReaders = ["group:engineering", "group:support"]; 'schemas/tenant.dcdl': `Tenant = {
defaultWriters = ["group:platform"]; slug = String;
breakglassAdmins = ["user:oncall-primary", "user:oncall-secondary"]; display_name = String;
`, plan = String default "standard";
'policy/teams.dcdl': `platform = ["group:platform-admins"]; limits = {
payments = ["group:payments-api", "group:payments-sre"]; users = Int & > 0 default 25;
observability = ["group:observability"]; projects = Int & > 0 default 20;
`, storage_gb = Int & > 0 default 100;
}, };
}, features = {
{ sso = Bool default false;
id: 'edge-worker', audit_log = Bool default true;
title: 'Edge worker config', data_residency = Bool default false;
activePath: 'main.dcdl', };
files: { compliance = {
'main.dcdl': `let region = String default "us";
schema = import "./schemas/worker.dcdl"; retention_days = Int & >= 30 default 90;
routes = import "./routes.dcdl"; };
production = true; billing = {
in currency = String default "USD";
schema.Worker & { invoice_day = Int & >= 1 & <= 28 default 1;
name = "decodal-docs"; };
compatibility_date = "2026-06-15";
workers_dev = !production;
route.host = routes.primary_host;
route.paths = routes.docs_paths ++ routes.playground_paths;
cache.ttl_seconds = 60 * 60;
cache.bypass = !production;
}
`,
'schemas/worker.dcdl': `Worker = {
name = String;
compatibility_date = String;
workers_dev = Bool default false;
route.host = String;
cache.ttl_seconds = Int & >= 0 default 300;
cache.bypass = Bool default false;
}; };
`, `,
'routes.dcdl': `primary_host = "decodal.example.com"; 'plans.dcdl': `Enterprise = {
docs_paths = ["/docs/*", "/assets/*"]; plan = "enterprise";
playground_paths = ["/playground/*"]; limits = {
users = 500;
projects = 100;
storage_gb = 1024;
};
features = {
sso = true;
audit_log = true;
};
billing.invoice_day = 15;
};
`,
'regions/eu.dcdl': `Europe = {
compliance = {
region = "eu-west";
retention_days = 365;
};
billing.currency = "EUR";
};
`,
'customers/acme.dcdl': `seats = 475;
contract_tier = "regulated";
notification_channels = ["email", "slack", "pagerduty"];
Acme = {
billing.invoice_day = 10;
};
`, `,
}, },
}, },
{ {
id: 'data-pipeline', id: 'event-pipeline-prod',
title: 'Data pipeline', title: 'Event ingestion pipeline',
activePath: 'main.dcdl', activePath: 'main.dcdl',
entryPath: 'main.dcdl',
files: { files: {
'main.dcdl': `let 'main.dcdl': `let
schema = import "./schemas/pipeline.dcdl"; schema = import "./schemas/pipeline.dcdl";
presets = import "./presets/batch.dcdl"; env = import "./env/prod.dcdl";
env = "staging"; sources = import "./sources/events.dcdl";
sinks = import "./sinks/warehouse.dcdl";
transforms = import "./transforms.dcdl";
in in
schema.Pipeline & presets.Batch & { schema.Pipeline & env.Production & {
name = "events-rollup"; name = "events-rollup";
source.topic = "events.raw"; source = {
sink.table = "analytics.events_daily"; kafka_cluster = sources.cluster;
workers = match env { topic = sources.raw_topic;
"prod": 8; consumer_group = "analytics-events-rollup";
"staging": 3;
_: 1;
}; };
alerts.enabled = env != "dev"; sink = {
transforms = presets.standardTransforms ++ ["dedupe", "aggregate_daily"]; table = sinks.daily_events_table;
mode = "append";
};
workers = env.scale.worker_count;
batch = {
size = env.scale.batch_size;
timeout_seconds = 60 + 30;
};
schedule.cron = "*/15 * * * *";
transforms = transforms.standard ++ transforms.privacy ++ ["aggregate_daily"];
quality.max_lag_seconds = match env.tier {
"gold": 120;
"silver": 300;
_: 900;
};
alerts.channels = env.alert_channels;
} }
`, `,
'schemas/pipeline.dcdl': `Pipeline = { 'schemas/pipeline.dcdl': `Pipeline = {
name = String; name = String;
source.topic = String; source = {
sink.table = String; kafka_cluster = String;
workers = Int & > 0 default 1; topic = String;
batch.size = Int & >= 100 default 1000; consumer_group = String;
batch.timeout_seconds = Int & > 0 default 60; };
alerts.enabled = Bool default false; sink = {
table = String;
mode = String default "append";
};
workers = Int & > 0 default 2;
batch = {
size = Int & >= 100 default 1000;
timeout_seconds = Int & > 0 default 60;
};
schedule.cron = String;
quality.max_lag_seconds = Int & > 0 default 300;
dead_letter = {
enabled = Bool default true;
topic = String default "events.dead_letter";
};
}; };
`, `,
'presets/batch.dcdl': `standardTransforms = ["parse_json", "validate_schema", "enrich_metadata"]; 'env/prod.dcdl': `tier = "gold";
alert_channels = ["slack:data-platform", "pagerduty:data-primary"];
Batch = { scale = {
batch.size = 5000; worker_count = 12;
batch.timeout_seconds = 30 + 30; batch_size = 5000;
};
Production = {
dead_letter.enabled = true;
};
`,
'sources/events.dcdl': `cluster = "kafka-prod-use1";
raw_topic = "events.raw.v3";
`,
'sinks/warehouse.dcdl': `daily_events_table = "analytics.events_daily";
`,
'transforms.dcdl': `standard = ["parse_json", "validate_schema", "dedupe"];
privacy = ["redact_ip", "hash_user_id"];
`,
},
},
{
id: 'edge-cdn-policy',
title: 'Edge CDN routing policy',
activePath: 'main.dcdl',
entryPath: 'main.dcdl',
files: {
'main.dcdl': `let
schema = import "./schemas/edge.dcdl";
domains = import "./domains.dcdl";
security = import "./security.dcdl";
env = "production";
in
schema.EdgeApplication & security.StrictHeaders & {
name = "docs-and-playground";
domains = domains.primary ++ domains.aliases;
origin = {
host = "origin-docs.internal.example.com";
port = 443;
};
tls.mode = "strict";
cache = {
default_ttl_seconds = 60 * 60;
static_ttl_seconds = 60 * 60 * 24;
bypass_preview = env != "production";
};
routing = {
docs_paths = ["/docs/*", "/assets/*"];
playground_paths = ["/playground/*"];
};
waf = {
enabled = true;
rules = security.managed_rules ++ ["block-country:kp", "rate-limit-login"];
};
}
`,
'schemas/edge.dcdl': `EdgeApplication = {
name = String;
origin = {
host = String;
port = Int & >= 1 & <= 65535 default 443;
};
tls.mode = String default "strict";
cache = {
default_ttl_seconds = Int & >= 0 default 300;
static_ttl_seconds = Int & >= 0 default 86400;
bypass_preview = Bool default false;
};
waf.enabled = Bool default true;
};
`,
'domains.dcdl': `primary = ["decodal.example.com"];
aliases = ["www.decodal.example.com", "docs.decodal.example.com"];
`,
'security.dcdl': `managed_rules = ["owasp-core", "known-bots", "credential-stuffing"];
StrictHeaders = {
headers = {
hsts = "max-age=31536000; includeSubDomains; preload";
referrer_policy = "same-origin";
frame_options = "DENY";
};
}; };
`, `,
}, },
}, },
{ {
id: 'diagnostics', id: 'incident-escalation',
title: 'Diagnostics: invalid port', title: 'Incident escalation policy',
activePath: 'main.dcdl', activePath: 'main.dcdl',
entryPath: 'main.dcdl',
files: { files: {
'main.dcdl': `let 'main.dcdl': `let
schema = import "./schemas/service.dcdl"; schema = import "./schemas/oncall.dcdl";
teams = import "./teams.dcdl";
calendar = import "./calendar.dcdl";
severity = "sev1";
in in
schema.Service & { schema.Policy & {
name = "broken-api"; service = "payments";
port = 442; severity = severity;
primary = teams.payments_primary;
secondary = teams.platform_secondary;
notify = teams.payments_primary ++ teams.platform_secondary ++ calendar.executive_watch;
response_minutes = match severity {
"sev1": 5;
"sev2": 15;
_: 60;
};
escalation = {
after_minutes = match severity {
"sev1": 10;
"sev2": 30;
_: 120;
};
targets = teams.platform_secondary ++ calendar.executive_watch;
};
runbooks = ["runbooks/payments-api", "runbooks/database-failover"];
} }
`, `,
'schemas/service.dcdl': `Service = { 'schemas/oncall.dcdl': `Policy = {
name = String; service = String;
port = Int & > 443 default 8443; severity = String;
replicas = Int & > 0 default 2; response_minutes = Int & > 0 default 30;
escalation.after_minutes = Int & > 0 default 60;
auto_page = Bool default true;
create_status_page = Bool default true;
}; };
`,
'teams.dcdl': `payments_primary = ["user:pay-oncall-a", "user:pay-oncall-b"];
platform_secondary = ["user:platform-sre-a", "user:platform-sre-b"];
`,
'calendar.dcdl': `executive_watch = ["user:vp-engineering", "user:cto"];
`, `,
}, },
}, },
+79 -10
View File
@@ -1,21 +1,26 @@
import init, { evaluateProject } from 'decodal-wasm'; import initRuntime, { evaluateProject } from 'decodal-wasm';
import runtimeWasmUrl from 'decodal-wasm/decodal_wasm_bg.wasm?url';
import { EditorView, basicSetup } from 'codemirror'; import { EditorView, basicSetup } from 'codemirror';
import { keymap } from '@codemirror/view'; import { keymap } from '@codemirror/view';
import { decodal } from 'decodal-codemirror'; import { decodal } from 'decodal-codemirror';
import { formatDecodal, initDecodalFormatter } from 'decodal-codemirror/format';
import toolsWasmUrl from 'decodal-codemirror/wasm/decodal_language_tools_bg.wasm?url';
import { playgroundExamples } from './playground-examples.js'; import { playgroundExamples } from './playground-examples.js';
const STORAGE_KEY = 'decodal-playground-project-v1'; const STORAGE_KEY = 'decodal-playground-project-v2';
const starterProject = playgroundExamples[0]; const starterProject = playgroundExamples[0];
const editorHost = document.getElementById('editor'); const editorHost = document.getElementById('editor');
const output = document.getElementById('output'); const output = document.getElementById('output');
const run = document.getElementById('run'); const run = document.getElementById('run');
const formatButton = document.getElementById('format');
const status = document.getElementById('status'); const status = document.getElementById('status');
const fileTree = document.getElementById('file-tree'); const fileTree = document.getElementById('file-tree');
const activeFile = document.getElementById('active-file'); const activeFile = document.getElementById('active-file');
const newFile = document.getElementById('new-file'); const newFile = document.getElementById('new-file');
const deleteFile = document.getElementById('delete-file'); const deleteFile = document.getElementById('delete-file');
const exampleSelect = document.getElementById('example-select'); const exampleSelect = document.getElementById('example-select');
const entrySelect = document.getElementById('entry-select');
const loadExample = document.getElementById('load-example'); const loadExample = document.getElementById('load-example');
const project = loadProject(); const project = loadProject();
@@ -86,6 +91,7 @@ for (const example of playgroundExamples) {
exampleSelect.value = starterProject.id; exampleSelect.value = starterProject.id;
setActiveFile(project.activePath); setActiveFile(project.activePath);
updateRunLabel();
renderFileTree(); renderFileTree();
function loadProject() { function loadProject() {
@@ -94,12 +100,22 @@ function loadProject() {
if (stored && stored.files && typeof stored.activePath === 'string') { if (stored && stored.files && typeof stored.activePath === 'string') {
const files = normalizeFiles(stored.files); const files = normalizeFiles(stored.files);
const activePath = files[stored.activePath] === undefined ? Object.keys(files)[0] : stored.activePath; const activePath = files[stored.activePath] === undefined ? Object.keys(files)[0] : stored.activePath;
if (activePath) return { files, activePath }; if (activePath) {
const storedEntryPath = typeof stored.entryPath === 'string' ? normalizePath(stored.entryPath) : '';
const entryPath = files[storedEntryPath] === undefined
? files['main.dcdl'] === undefined ? activePath : 'main.dcdl'
: storedEntryPath;
return { files, activePath, entryPath };
}
} }
} catch (_error) { } catch (_error) {
// Fall back to the starter project. // Fall back to the starter project.
} }
return { files: cloneFiles(starterProject.files), activePath: starterProject.activePath }; return {
files: cloneFiles(starterProject.files),
activePath: starterProject.activePath,
entryPath: starterProject.entryPath ?? starterProject.activePath,
};
} }
function cloneFiles(files) { function cloneFiles(files) {
@@ -121,10 +137,13 @@ function saveProject() {
function loadExampleProject(exampleId) { function loadExampleProject(exampleId) {
const example = playgroundExamples.find((item) => item.id === exampleId) ?? starterProject; const example = playgroundExamples.find((item) => item.id === exampleId) ?? starterProject;
project.files = cloneFiles(example.files); const files = cloneFiles(example.files);
project.activePath = example.activePath; project.files = {};
project.activePath = '';
project.entryPath = example.entryPath ?? example.activePath;
project.files = files;
exampleSelect.value = example.id; exampleSelect.value = example.id;
setActiveFile(project.activePath); setActiveFile(example.activePath);
output.textContent = ''; output.textContent = '';
output.classList.remove('error'); output.classList.remove('error');
execute(); execute();
@@ -165,17 +184,56 @@ function setActiveFile(path) {
activeFile.textContent = normalized; activeFile.textContent = normalized;
deleteFile.disabled = Object.keys(project.files).length <= 1; deleteFile.disabled = Object.keys(project.files).length <= 1;
renderFileTree(); renderFileTree();
updateRunLabel();
saveProject(); saveProject();
} }
function updateEntrySelect() {
const paths = Object.keys(project.files).sort();
const entryPath = project.files[project.entryPath] === undefined ? project.activePath : project.entryPath;
project.entryPath = entryPath;
entrySelect.replaceChildren(
...paths.map((path) => {
const option = document.createElement('option');
option.value = path;
option.textContent = path;
return option;
}),
);
entrySelect.value = entryPath;
}
function updateRunLabel() {
updateEntrySelect();
run.textContent = 'Run';
run.title = `Materialize ${project.entryPath}`;
}
function execute() { function execute() {
project.files[project.activePath] = getEditorText(); project.files[project.activePath] = getEditorText();
const entryPath = project.files[project.entryPath] === undefined ? project.activePath : project.entryPath;
project.entryPath = entryPath;
updateRunLabel();
saveProject(); saveProject();
const result = JSON.parse(evaluateProject(project.activePath, JSON.stringify(project.files))); const result = JSON.parse(evaluateProject(entryPath, JSON.stringify(project.files)));
output.textContent = result.ok ? result.output : result.error; output.textContent = result.ok ? result.output : result.error;
output.classList.toggle('error', !result.ok); output.classList.toggle('error', !result.ok);
} }
function formatActiveFile() {
const result = formatDecodal(getEditorText());
if (!result.ok) {
output.textContent = result.error;
output.classList.add('error');
return;
}
setEditorText(result.source);
project.files[project.activePath] = result.source;
saveProject();
output.textContent = 'Formatted.';
output.classList.remove('error');
}
function renderFileTree() { function renderFileTree() {
const tree = buildTree(Object.keys(project.files).sort()); const tree = buildTree(Object.keys(project.files).sort());
fileTree.replaceChildren(renderTreeList(tree.children)); fileTree.replaceChildren(renderTreeList(tree.children));
@@ -227,19 +285,27 @@ function compareNodes(a, b) {
} }
try { try {
await init(); await Promise.all([initRuntime(runtimeWasmUrl), initDecodalFormatter(toolsWasmUrl)]);
run.disabled = false; run.disabled = false;
formatButton.disabled = false;
status.textContent = ''; status.textContent = '';
execute(); execute();
} catch (error) { } catch (error) {
status.textContent = `Failed to load WASM: ${error?.message ?? error}`; status.textContent = `Failed to load WASM: ${error?.message ?? error}`;
} }
entrySelect.addEventListener('change', () => {
const path = normalizePath(entrySelect.value);
if (project.files[path] === undefined) return;
project.entryPath = path;
updateRunLabel();
saveProject();
});
run.addEventListener('click', execute); run.addEventListener('click', execute);
formatButton.addEventListener('click', formatActiveFile);
loadExample.addEventListener('click', () => { loadExample.addEventListener('click', () => {
const example = playgroundExamples.find((item) => item.id === exampleSelect.value); const example = playgroundExamples.find((item) => item.id === exampleSelect.value);
if (!example) return; if (!example) return;
if (!confirm(`Load example "${example.title}"? This replaces the current virtual files.`)) return;
loadExampleProject(example.id); loadExampleProject(example.id);
}); });
newFile.addEventListener('click', () => { newFile.addEventListener('click', () => {
@@ -256,5 +322,8 @@ deleteFile.addEventListener('click', () => {
if (Object.keys(project.files).length <= 1) return; if (Object.keys(project.files).length <= 1) return;
if (!confirm(`Delete ${project.activePath}?`)) return; if (!confirm(`Delete ${project.activePath}?`)) return;
delete project.files[project.activePath]; delete project.files[project.activePath];
if (project.files[project.entryPath] === undefined) {
project.entryPath = Object.keys(project.files).sort()[0];
}
setActiveFile(Object.keys(project.files).sort()[0]); setActiveFile(Object.keys(project.files).sort()[0]);
}); });
+54 -21
View File
@@ -293,11 +293,23 @@ main.playground {
align-items: center; align-items: center;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
} }
.example-picker { .action-group {
align-items: center;
display: flex;
gap: 8px;
}
.action-separator {
color: var(--border);
font-weight: 700;
}
.example-picker,
.entry-picker {
align-items: center; align-items: center;
color: var(--muted); color: var(--muted);
display: flex; display: flex;
@@ -307,17 +319,23 @@ main.playground {
text-transform: uppercase; text-transform: uppercase;
} }
.example-picker select { .example-picker select,
.entry-picker select {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
color: var(--text); color: var(--text);
font: inherit; font: inherit;
min-height: 28px;
min-width: 180px; min-width: 180px;
padding: 7px 10px; padding: 4px 10px;
text-transform: none; text-transform: none;
} }
.entry-picker select {
max-width: 220px;
}
button { button {
background: var(--link); background: var(--link);
border: 0; border: 0;
@@ -325,7 +343,8 @@ button {
color: var(--topbar-text); color: var(--topbar-text);
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
padding: 8px 14px; min-height: 28px;
padding: 4px 14px;
} }
button:disabled { button:disabled {
@@ -519,45 +538,57 @@ button:disabled {
} }
.home-packages { .home-packages {
align-items: flex-start;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 18px; border-radius: 18px;
display: flex;
flex-wrap: wrap;
gap: 0.9rem 1.4rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 1.4rem; padding: 1.2rem 1.4rem;
} }
.home-packages h2 { .home-packages h2 {
margin-top: 0; flex-basis: 100%;
margin: 0;
} }
.package-links { .primary-package {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.package-links a {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 14px; border-radius: 14px;
display: block; display: block;
padding: 1rem; flex: 0 1 320px;
padding: 0.9rem 1rem;
text-decoration: none; text-decoration: none;
} }
.package-links span { .primary-package span {
color: var(--muted); color: var(--muted);
display: block; display: block;
font-size: 0.9rem; font-size: 0.9rem;
margin-bottom: 0.3rem; margin-bottom: 0.25rem;
} }
.package-links strong { .primary-package strong {
color: var(--text); color: var(--text);
display: block; display: block;
font-size: 0.95rem; font-size: 1.15rem;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.package-list {
columns: 1;
flex: 1 1 320px;
margin: 0;
padding-left: 1.2rem;
}
.package-list li {
break-inside: avoid;
margin: 0.25rem 0;
}
.home-grid { .home-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -604,10 +635,12 @@ button:disabled {
} }
.playground-shell, .playground-shell,
.home-grid, .home-grid,
.home-example, .home-example {
.package-links {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.package-list {
columns: 1;
}
.hero { .hero {
padding-top: 3rem; padding-top: 3rem;
} }