mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
`extract_original` split the wrapper's inner text on newlines and kept the first and last pieces, which are the line breaks around the original element inside `<div data-impeccable-variant="original">`. Every discard therefore left one empty line before and one after the restored element. `extract_variant` already trims those; the original now gets the same trim. Test writes a JSX wrapper to disk, discards, and checks the file byte for byte. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1472 lines
56 KiB
Rust
1472 lines
56 KiB
Rust
//! JS: live-accept.mjs -> `impeccable live-accept` / `accept`. Deterministic
|
|
//! accept/discard of variant sessions: HTML/JSX wrapper carbonize, the
|
|
//! Svelte component inline path, receipts, source locks, buffer scrub.
|
|
|
|
use crate::paths::{live_dir, safe_session_id};
|
|
use crate::pending_edits::{read_buffer, write_buffer};
|
|
use crate::roots::enter_live_root;
|
|
use crate::source_lock::with_source_lock;
|
|
use crate::source_search::{find_source_file, is_generated_file, resolve_live_template_extensions};
|
|
use crate::svelte_component::{
|
|
find_svelte_component_manifest, inline_svelte_component_accept, remove_svelte_component_session,
|
|
};
|
|
use crate::util::{eprintln, iso_now, json_compact, json_pretty, jsp, println, safe_read, Env};
|
|
use crate::wrap_common::{arg_val, leading_ws, min_leading_spaces, slice_chars};
|
|
use impeccable_common::Io;
|
|
use impeccable_core::js::{trim, trim_start, WS};
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
use serde_json::{json, Map, Value};
|
|
|
|
const ACCEPT_LOCK_WAIT_MS: f64 = 1000.0;
|
|
|
|
const HELP: &str = "Usage: impeccable live-accept [options]
|
|
|
|
Deterministic accept/discard for live variant sessions.
|
|
|
|
Modes:
|
|
--discard Remove variants, restore original
|
|
--variant N Accept variant N, discard the rest
|
|
|
|
Required:
|
|
--id SESSION_ID Session ID of the variant wrapper
|
|
|
|
Options:
|
|
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
|
|
--bake Bake a knob-free HTML/JSX accept mechanically (rules to the
|
|
owning stylesheet, wrapper unwrapped) instead of leaving
|
|
the carbonize block; opt-in, never the default
|
|
--no-bake Never bake; always leave the carbonize block
|
|
--defer-source-write
|
|
Deprecated compatibility flag. Svelte component accepts
|
|
now write the real source immediately.
|
|
|
|
Output (JSON):
|
|
{ handled, file, carbonize, baked?, css?, bakeSkipped? }";
|
|
|
|
/// What a bake needs beyond the file: where to look for the stylesheet and
|
|
/// which session to name in it.
|
|
struct BakeRequest {
|
|
cwd: String,
|
|
session_id: String,
|
|
/// The element descriptor the overlay journaled with the session's
|
|
/// generate event: the anchor it saw and how many elements matched it.
|
|
element: Option<Map<String, Value>>,
|
|
}
|
|
|
|
/// The `element` of the session's journaled generate event, if any.
|
|
fn journaled_element(cwd: &str, env: &Env, id: &str) -> Option<Map<String, Value>> {
|
|
let state = crate::session::create_live_session_store(cwd, env, Some(id)).read_state(id, true).ok()?;
|
|
let journal = safe_read(&state.journal_path)?;
|
|
journal
|
|
.lines()
|
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
|
.find_map(|entry| {
|
|
let event = entry.get("event")?;
|
|
if event.get("type").and_then(Value::as_str) != Some("generate") {
|
|
return None;
|
|
}
|
|
event.get("element").and_then(Value::as_object).cloned()
|
|
})
|
|
}
|
|
|
|
/// The two ways an HTML/JSX accept can end.
|
|
enum AcceptOutcome {
|
|
/// The carbonize block is in source (or nothing was needed).
|
|
Carbonized {
|
|
carbonize: bool,
|
|
original: String,
|
|
bake_skipped: Option<String>,
|
|
},
|
|
/// The variant is permanent: rules appended to `css_file` (None: the
|
|
/// page's own `<style>` block), wrapper gone.
|
|
Baked {
|
|
original: String,
|
|
css_file: Option<String>,
|
|
rules: usize,
|
|
anchor: String,
|
|
},
|
|
}
|
|
|
|
pub fn run(args: &[String], io: &mut Io) -> i32 {
|
|
let mut argv: Vec<String> = args.to_vec();
|
|
if let Err(code) = enter_live_root(&mut argv, io) {
|
|
return code;
|
|
}
|
|
accept_cli(&argv, io)
|
|
}
|
|
|
|
fn nonempty(v: Option<String>) -> Option<String> {
|
|
v.filter(|s| !s.is_empty())
|
|
}
|
|
|
|
/// JS: operationFailure(err, extra)
|
|
fn operation_failure(message: &str, extra: &[(&str, Value)]) -> Value {
|
|
let mut m = Map::new();
|
|
m.insert("handled".into(), Value::Bool(false));
|
|
m.insert("mode".into(), Value::String("error".into()));
|
|
m.insert("error".into(), Value::String(message.to_string()));
|
|
for (k, v) in extra {
|
|
m.insert(k.to_string(), v.clone());
|
|
}
|
|
Value::Object(m)
|
|
}
|
|
|
|
/// JS: markPreviewFailure(result)
|
|
fn mark_preview_failure(result: Value) -> Value {
|
|
if let Value::Object(mut m) = result {
|
|
let unhandled = m.get("handled") == Some(&Value::Bool(false));
|
|
let no_mode = m
|
|
.get("mode")
|
|
.map(|v| !crate::inject::detect_utils::truthy(v))
|
|
.unwrap_or(true);
|
|
let preview = m
|
|
.get("previewMode")
|
|
.map(crate::inject::detect_utils::truthy)
|
|
.unwrap_or(false);
|
|
if unhandled && no_mode && preview {
|
|
m.insert("mode".into(), Value::String("error".into()));
|
|
}
|
|
Value::Object(m)
|
|
} else {
|
|
result
|
|
}
|
|
}
|
|
|
|
fn accept_receipt_path(cwd: &str, env: &Env, id: &str) -> String {
|
|
jsp::join(&[
|
|
&live_dir(cwd, env),
|
|
"accept-receipts",
|
|
&format!("{}.json", id),
|
|
])
|
|
}
|
|
|
|
fn read_accept_receipt(cwd: &str, env: &Env, id: &str) -> Option<Value> {
|
|
let text = safe_read(&accept_receipt_path(cwd, env, id))?;
|
|
serde_json::from_str(&text).ok()
|
|
}
|
|
|
|
fn write_accept_receipt(
|
|
cwd: &str,
|
|
env: &Env,
|
|
id: &str,
|
|
operation: &str,
|
|
variant_id: Option<&str>,
|
|
result: &Value,
|
|
) {
|
|
let file = accept_receipt_path(cwd, env, id);
|
|
if let Some(parent) = std::path::Path::new(&file).parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let mut m = Map::new();
|
|
m.insert("id".into(), Value::String(id.to_string()));
|
|
m.insert("operation".into(), Value::String(operation.to_string()));
|
|
m.insert(
|
|
"variantId".into(),
|
|
variant_id
|
|
.map(|v| Value::String(v.to_string()))
|
|
.unwrap_or(Value::Null),
|
|
);
|
|
m.insert("result".into(), result.clone());
|
|
m.insert("completedAt".into(), Value::String(iso_now()));
|
|
let tmp = format!(
|
|
"{}.{}.{}.tmp",
|
|
file,
|
|
std::process::id(),
|
|
crate::util::now_ms() as i64
|
|
);
|
|
if std::fs::write(&tmp, format!("{}\n", json_pretty(&Value::Object(m)))).is_ok() {
|
|
let _ = std::fs::rename(&tmp, &file);
|
|
}
|
|
}
|
|
|
|
fn accept_cli(args: &[String], io: &mut Io) -> i32 {
|
|
if args.iter().any(|a| a == "--help" || a == "-h") {
|
|
println(io, HELP);
|
|
return 0;
|
|
}
|
|
let cwd = io.cwd.to_string_lossy().into_owned();
|
|
let env = io.env.clone();
|
|
|
|
let id = nonempty(arg_val(args, "--id"));
|
|
let variant_num = nonempty(arg_val(args, "--variant"));
|
|
let param_values_raw = nonempty(arg_val(args, "--param-values"));
|
|
let page_url = nonempty(arg_val(args, "--page-url"));
|
|
let is_discard = args.iter().any(|a| a == "--discard");
|
|
let bake_flag = args.iter().any(|a| a == "--bake");
|
|
let no_bake = args.iter().any(|a| a == "--no-bake");
|
|
|
|
let Some(id) = id else {
|
|
eprintln(io, "Missing --id");
|
|
return 1;
|
|
};
|
|
if safe_session_id(&id).is_err() {
|
|
eprintln(io, "Invalid --id");
|
|
return 1;
|
|
}
|
|
if !is_discard && variant_num.is_none() {
|
|
eprintln(io, "Need --discard or --variant N");
|
|
return 1;
|
|
}
|
|
if !is_discard {
|
|
let v = variant_num.as_deref().unwrap_or("");
|
|
let ok = !v.is_empty() && v.len() <= 3 && v.bytes().all(|b| b.is_ascii_digit());
|
|
if !ok {
|
|
eprintln(io, "Invalid --variant");
|
|
return 1;
|
|
}
|
|
}
|
|
let variant_num = variant_num.unwrap_or_default();
|
|
|
|
let requested_operation = if is_discard { "discard" } else { "accept" };
|
|
if let Some(prior) = read_accept_receipt(&cwd, &env, &id) {
|
|
let prior_op = prior
|
|
.get("operation")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let prior_variant = prior.get("variantId").cloned().unwrap_or(Value::Null);
|
|
let same = prior_op == requested_operation
|
|
&& (is_discard || crate::accept_css::js_string(&prior_variant) == variant_num);
|
|
let out = if same {
|
|
let mut m = match prior.get("result") {
|
|
Some(Value::Object(o)) => o.clone(),
|
|
_ => Map::new(),
|
|
};
|
|
m.insert("handled".into(), Value::Bool(true));
|
|
m.insert("alreadyApplied".into(), Value::Bool(true));
|
|
Value::Object(m)
|
|
} else {
|
|
json!({
|
|
"handled": false,
|
|
"mode": "error",
|
|
"error": "accept_receipt_conflict",
|
|
"priorOperation": prior.get("operation").cloned().unwrap_or(Value::Null),
|
|
"priorVariantId": prior_variant,
|
|
})
|
|
};
|
|
println(io, &json_compact(&out));
|
|
return 0;
|
|
}
|
|
|
|
let emit_result = |io: &mut Io, raw: Value| {
|
|
let result = mark_preview_failure(raw);
|
|
if result.get("handled") != Some(&Value::Bool(false)) {
|
|
write_accept_receipt(
|
|
&cwd,
|
|
&env,
|
|
&id,
|
|
requested_operation,
|
|
if is_discard {
|
|
None
|
|
} else {
|
|
Some(variant_num.as_str())
|
|
},
|
|
&result,
|
|
);
|
|
}
|
|
println(io, &json_compact(&result));
|
|
};
|
|
|
|
let param_values: Option<Map<String, Value>> =
|
|
param_values_raw
|
|
.as_deref()
|
|
.and_then(|raw| match serde_json::from_str::<Value>(raw) {
|
|
Ok(Value::Object(o)) => Some(o),
|
|
_ => None,
|
|
});
|
|
|
|
let found = find_session_file(&id, &cwd);
|
|
let svelte_manifest = if found.is_some() {
|
|
None
|
|
} else {
|
|
match find_svelte_component_manifest(&id, &cwd) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
eprintln(io, &format!("Error: {}", e));
|
|
return 1;
|
|
}
|
|
}
|
|
};
|
|
|
|
if found.is_none() && svelte_manifest.is_none() {
|
|
println(
|
|
io,
|
|
&json_compact(
|
|
&json!({ "handled": false, "error": format!("Session markers not found for id: {}", id) }),
|
|
),
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
if let Some(manifest) = svelte_manifest {
|
|
let source_file = manifest.get("sourceFile").cloned().unwrap_or(Value::Null);
|
|
let component_dir = manifest.get("componentDir").cloned().unwrap_or(Value::Null);
|
|
let mut context: Vec<(&str, Value)> = vec![("file", source_file.clone())];
|
|
if is_discard {
|
|
context.push(("carbonize", Value::Bool(false)));
|
|
} else {
|
|
context.push(("sourceFile", source_file.clone()));
|
|
}
|
|
context.push(("previewMode", Value::String("svelte-component".into())));
|
|
context.push(("componentDir", component_dir));
|
|
|
|
let sf_str = crate::accept_css::js_string(&source_file);
|
|
let lock_file = jsp::resolve(&cwd, &[&sf_str]);
|
|
let owner = format!("{}:{}", requested_operation, id);
|
|
let locked = with_source_lock(&lock_file, &owner, &cwd, &env, ACCEPT_LOCK_WAIT_MS, || {
|
|
if is_discard {
|
|
remove_svelte_component_session(&id, &cwd);
|
|
let mut m = Map::new();
|
|
m.insert("handled".into(), Value::Bool(true));
|
|
for (k, v) in &context {
|
|
m.insert(k.to_string(), v.clone());
|
|
}
|
|
Ok(Value::Object(m))
|
|
} else {
|
|
inline_svelte_component_accept(&manifest, &variant_num, param_values.as_ref(), &cwd)
|
|
}
|
|
});
|
|
let mut result = match locked {
|
|
Ok(Ok(v)) => v,
|
|
Ok(Err(msg)) => operation_failure(&msg, &context),
|
|
Err(lock) => operation_failure(&lock.message, &context),
|
|
};
|
|
if let Value::Object(m) = &mut result {
|
|
if m.get("carbonize")
|
|
.map(crate::inject::detect_utils::truthy)
|
|
.unwrap_or(false)
|
|
{
|
|
let file = m
|
|
.get("file")
|
|
.map(crate::accept_css::js_string)
|
|
.unwrap_or_default();
|
|
m.insert(
|
|
"todo".into(),
|
|
Value::String(format!(
|
|
"REQUIRED before next poll: carbonize cleanup in {}. See reference/live.md \"Required after accept\".",
|
|
file
|
|
)),
|
|
);
|
|
}
|
|
let handled = m.get("handled") != Some(&Value::Bool(false));
|
|
m.insert("handled".into(), Value::Bool(handled));
|
|
// `{ handled, ...result }`: handled stays first (it already is).
|
|
}
|
|
emit_result(io, result);
|
|
return 0;
|
|
}
|
|
|
|
let (target_file, content, lines) = found.unwrap();
|
|
let rel_file = jsp::relative("/", &cwd, &target_file);
|
|
let preview_block = find_marker_block(&id, &lines);
|
|
let source_shadow = preview_block.is_some() && read_source_shadow_preview_meta(&content, &id);
|
|
if source_shadow {
|
|
println(
|
|
io,
|
|
&json_compact(&json!({
|
|
"handled": false,
|
|
"error": "source_shadow_preview_deprecated",
|
|
"hint": "Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.",
|
|
})),
|
|
);
|
|
return 0;
|
|
}
|
|
if is_generated_file(&target_file, &cwd) {
|
|
println(
|
|
io,
|
|
&json_compact(&json!({
|
|
"handled": false,
|
|
"mode": "fallback",
|
|
"file": rel_file,
|
|
"hint": "Session is in a generated file. Persist the accepted variant in source; do not rely on this script.",
|
|
})),
|
|
);
|
|
return 0;
|
|
}
|
|
|
|
if is_discard {
|
|
let owner = format!("discard:{}", id);
|
|
let locked = with_source_lock(
|
|
&target_file,
|
|
&owner,
|
|
&cwd,
|
|
&env,
|
|
ACCEPT_LOCK_WAIT_MS,
|
|
|| {
|
|
let lines: Vec<String> = safe_read(&target_file)
|
|
.unwrap_or_default()
|
|
.split('\n')
|
|
.map(String::from)
|
|
.collect();
|
|
handle_discard_unlocked(&id, &lines, &target_file)
|
|
},
|
|
);
|
|
match locked {
|
|
Err(lock) => {
|
|
emit_result(
|
|
io,
|
|
operation_failure(&lock.message, &[("file", Value::String(rel_file))]),
|
|
);
|
|
}
|
|
Ok(result) => {
|
|
let mut m = Map::new();
|
|
m.insert("handled".into(), Value::Bool(true));
|
|
m.insert("file".into(), Value::String(rel_file));
|
|
m.insert("carbonize".into(), Value::Bool(false));
|
|
match result {
|
|
Ok(()) => {}
|
|
Err(err) => {
|
|
m.insert("handled".into(), Value::Bool(false));
|
|
m.insert("error".into(), Value::String(err));
|
|
}
|
|
}
|
|
emit_result(io, Value::Object(m));
|
|
}
|
|
}
|
|
} else {
|
|
// Only --bake asks for the mechanical bake. Every session, the
|
|
// generate lane's included, keeps the carbonize block by default:
|
|
// the agent integrates the accepted variant the way live.md says,
|
|
// which is what makes the result read as designed rather than
|
|
// appended.
|
|
let bake = if !no_bake && bake_flag {
|
|
Some(BakeRequest { cwd: cwd.clone(), session_id: id.clone(), element: journaled_element(&cwd, &env, &id) })
|
|
} else {
|
|
None
|
|
};
|
|
let owner = format!("accept:{}", id);
|
|
let locked = with_source_lock(
|
|
&target_file,
|
|
&owner,
|
|
&cwd,
|
|
&env,
|
|
ACCEPT_LOCK_WAIT_MS,
|
|
|| {
|
|
let lines: Vec<String> = safe_read(&target_file)
|
|
.unwrap_or_default()
|
|
.split('\n')
|
|
.map(String::from)
|
|
.collect();
|
|
handle_accept_unlocked(
|
|
&id,
|
|
&variant_num,
|
|
&lines,
|
|
&target_file,
|
|
param_values.as_ref(),
|
|
bake.as_ref(),
|
|
)
|
|
},
|
|
);
|
|
match locked {
|
|
Err(lock) => {
|
|
emit_result(
|
|
io,
|
|
operation_failure(&lock.message, &[("file", Value::String(rel_file))]),
|
|
);
|
|
}
|
|
Ok(Err(err)) => {
|
|
// JS: `{ handled: true, file, ...result }` with result.handled false.
|
|
let mut m = Map::new();
|
|
m.insert("handled".into(), Value::Bool(false));
|
|
m.insert("file".into(), Value::String(rel_file));
|
|
m.insert("error".into(), Value::String(err));
|
|
emit_result(io, Value::Object(m));
|
|
}
|
|
Ok(Ok(outcome)) => {
|
|
let mut m = Map::new();
|
|
m.insert("handled".into(), Value::Bool(true));
|
|
m.insert("file".into(), Value::String(rel_file.clone()));
|
|
let accepted_original_text = match outcome {
|
|
AcceptOutcome::Carbonized { carbonize, original, bake_skipped } => {
|
|
m.insert("carbonize".into(), Value::Bool(carbonize));
|
|
if carbonize {
|
|
m.insert(
|
|
"todo".into(),
|
|
Value::String(format!(
|
|
"REQUIRED before next poll: carbonize cleanup in {}. See reference/live.md \"Required after accept\".",
|
|
rel_file
|
|
)),
|
|
);
|
|
}
|
|
if let Some(why) = bake_skipped {
|
|
m.insert("bakeSkipped".into(), Value::String(why));
|
|
}
|
|
original
|
|
}
|
|
AcceptOutcome::Baked { original, css_file, rules, anchor } => {
|
|
m.insert("carbonize".into(), Value::Bool(false));
|
|
m.insert("baked".into(), Value::Bool(true));
|
|
m.insert("variant".into(), Value::String(variant_num.clone()));
|
|
m.insert(
|
|
"css".into(),
|
|
json!({
|
|
"file": css_file.as_deref().map(|f| jsp::to_posix(&jsp::relative("/", &cwd, f))),
|
|
"rules": rules,
|
|
"anchor": anchor,
|
|
}),
|
|
);
|
|
let (clean, findings, _) = crate::accept_verify::verify_accepted_file(&target_file);
|
|
m.insert("verify".into(), json!({ "clean": clean, "findings": findings }));
|
|
original
|
|
}
|
|
};
|
|
scrub_manual_edits_against_original_block(
|
|
&accepted_original_text,
|
|
&cwd,
|
|
&env,
|
|
page_url.as_deref(),
|
|
);
|
|
emit_result(io, Value::Object(m));
|
|
}
|
|
}
|
|
}
|
|
0
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Buffer scrub
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static TAG_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"<[^>]*>").unwrap());
|
|
static JSX_COMMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)\{/\*.*?\*/\}").unwrap());
|
|
static HTML_COMMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)<!--.*?-->").unwrap());
|
|
static WS_RUN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{}+", WS)).unwrap());
|
|
|
|
fn normalize_manual_edit_text(text: &str) -> String {
|
|
trim(&WS_RUN_RE.replace_all(text, " ")).to_string()
|
|
}
|
|
|
|
fn manual_edit_text_segments(source: &str) -> Vec<String> {
|
|
let a = TAG_RE.replace_all(source, "\n");
|
|
let b = JSX_COMMENT_RE.replace_all(&a, "\n");
|
|
let c = HTML_COMMENT_RE.replace_all(&b, "\n");
|
|
c.split('\n')
|
|
.map(normalize_manual_edit_text)
|
|
.filter(|s| !s.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn original_block_has_exact_manual_text(block: &str, text: &str) -> bool {
|
|
let needle = normalize_manual_edit_text(text);
|
|
if needle.is_empty() {
|
|
return false;
|
|
}
|
|
manual_edit_text_segments(block)
|
|
.iter()
|
|
.any(|s| *s == needle)
|
|
}
|
|
|
|
fn manual_edit_op_appears_in_block(op: &Value, block: &str) -> bool {
|
|
[op.get("newText"), op.get("originalText")]
|
|
.iter()
|
|
.filter_map(|v| v.and_then(|x| x.as_str()))
|
|
.filter(|s| !s.is_empty())
|
|
.any(|s| original_block_has_exact_manual_text(block, s))
|
|
}
|
|
|
|
/// JS: scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl)
|
|
fn scrub_manual_edits_against_original_block(
|
|
block: &str,
|
|
cwd: &str,
|
|
env: &Env,
|
|
page_url: Option<&str>,
|
|
) {
|
|
if block.is_empty() {
|
|
return;
|
|
}
|
|
let Some(page_url) = page_url else { return };
|
|
let mut entries = read_buffer(cwd, env);
|
|
if entries.is_empty() {
|
|
return;
|
|
}
|
|
let mut mutated = false;
|
|
for entry in entries.iter_mut() {
|
|
if entry.get("pageUrl").and_then(|v| v.as_str()) != Some(page_url) {
|
|
continue;
|
|
}
|
|
let ops: Vec<Value> = match entry.get("ops") {
|
|
Some(Value::Array(a)) => a.clone(),
|
|
_ => Vec::new(),
|
|
};
|
|
let before = ops.len();
|
|
let kept: Vec<Value> = ops
|
|
.into_iter()
|
|
.filter(|op| !manual_edit_op_appears_in_block(op, block))
|
|
.collect();
|
|
if kept.len() != before {
|
|
mutated = true;
|
|
}
|
|
if let Value::Object(o) = entry {
|
|
o.insert("ops".into(), Value::Array(kept));
|
|
}
|
|
}
|
|
let entries: Vec<Value> = entries
|
|
.into_iter()
|
|
.filter(|e| matches!(e.get("ops"), Some(Value::Array(a)) if !a.is_empty()))
|
|
.collect();
|
|
if mutated {
|
|
write_buffer(cwd, env, &entries);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discard / Accept
|
|
// ---------------------------------------------------------------------------
|
|
|
|
struct MarkerBlock {
|
|
start: usize,
|
|
end: usize,
|
|
}
|
|
|
|
/// JS: findMarkerBlock(id, lines)
|
|
fn find_marker_block(id: &str, lines: &[String]) -> Option<MarkerBlock> {
|
|
let start_pattern = format!("impeccable-variants-start {}", id);
|
|
let end_pattern = format!("impeccable-variants-end {}", id);
|
|
let mut start: Option<usize> = None;
|
|
let mut end: Option<usize> = None;
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if start.is_none() && line.contains(&start_pattern) {
|
|
start = Some(i);
|
|
}
|
|
if line.contains(&end_pattern) {
|
|
end = Some(i);
|
|
break;
|
|
}
|
|
}
|
|
match (start, end) {
|
|
(Some(s), Some(e)) => Some(MarkerBlock { start: s, end: e }),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn is_jsx_file(path: &str) -> bool {
|
|
let ext = jsp::extname(path).to_lowercase();
|
|
ext == ".jsx" || ext == ".tsx"
|
|
}
|
|
|
|
fn comment_syntax_for(path: &str) -> (&'static str, &'static str) {
|
|
if is_jsx_file(path) {
|
|
("{/*", "*/}")
|
|
} else {
|
|
("<!--", "-->")
|
|
}
|
|
}
|
|
|
|
/// JS: handleDiscardUnlocked → Ok(()) or Err(error)
|
|
fn handle_discard_unlocked(id: &str, lines: &[String], target_file: &str) -> Result<(), String> {
|
|
let Some(block) = find_marker_block(id, lines) else {
|
|
return Err("Markers not found".to_string());
|
|
};
|
|
let original = extract_original(lines, &block);
|
|
let is_jsx = is_jsx_file(target_file);
|
|
let (rs, re) = expand_replace_range(&block, lines, is_jsx, id);
|
|
let indent = leading_ws(&lines[rs]);
|
|
let restored = deindent_content(&original, &indent);
|
|
let mut new_lines: Vec<String> = Vec::new();
|
|
new_lines.extend_from_slice(&lines[..rs]);
|
|
new_lines.extend(restored);
|
|
new_lines.extend_from_slice(&lines[(re + 1).min(lines.len())..]);
|
|
let _ = std::fs::write(target_file, new_lines.join("\n"));
|
|
Ok(())
|
|
}
|
|
|
|
/// JS: buildCarbonizeReplacement({...})
|
|
fn build_carbonize_replacement(
|
|
indent: &str,
|
|
cs: (&str, &str),
|
|
is_jsx: bool,
|
|
id: &str,
|
|
variant_num: &str,
|
|
css: Option<&[String]>,
|
|
param_values: Option<&Map<String, Value>>,
|
|
restored: &[String],
|
|
) -> Vec<String> {
|
|
let mut lines: Vec<String> = Vec::new();
|
|
let Some(css) = css else {
|
|
lines.extend_from_slice(restored);
|
|
return lines;
|
|
};
|
|
let (co, cc) = cs;
|
|
let variant_style_attr = if is_jsx {
|
|
"style={{ display: 'contents' }}"
|
|
} else {
|
|
"style=\"display: contents\""
|
|
};
|
|
let push_body = |lines: &mut Vec<String>, body_indent: &str| {
|
|
let body_restored = reindent_content(restored, indent, &format!("{} ", body_indent));
|
|
lines.push(format!(
|
|
"{}{} impeccable-carbonize-start {} {}",
|
|
body_indent, co, id, cc
|
|
));
|
|
lines.push(format!(
|
|
"{}<style data-impeccable-css=\"{}\">{}",
|
|
body_indent,
|
|
id,
|
|
if is_jsx { "{`" } else { "" }
|
|
));
|
|
for css_line in css {
|
|
lines.push(format!("{}{}", body_indent, trim_start(css_line)));
|
|
}
|
|
lines.push(format!(
|
|
"{}{}",
|
|
body_indent,
|
|
if is_jsx { "`}</style>" } else { "</style>" }
|
|
));
|
|
if let Some(pv) = param_values {
|
|
if !pv.is_empty() {
|
|
lines.push(format!(
|
|
"{}{} impeccable-param-values {}: {} {}",
|
|
body_indent,
|
|
co,
|
|
id,
|
|
json_compact(&Value::Object(pv.clone())),
|
|
cc
|
|
));
|
|
}
|
|
}
|
|
lines.push(format!(
|
|
"{}{} impeccable-carbonize-end {} {}",
|
|
body_indent, co, id, cc
|
|
));
|
|
lines.push(format!(
|
|
"{}<div data-impeccable-variant=\"{}\" {}>",
|
|
body_indent, variant_num, variant_style_attr
|
|
));
|
|
lines.extend(body_restored);
|
|
lines.push(format!("{}</div>", body_indent));
|
|
};
|
|
if is_jsx {
|
|
lines.push(format!(
|
|
"{}<div data-impeccable-carbonize=\"{}\" style={{{{ display: \"contents\" }}}}>",
|
|
indent, id
|
|
));
|
|
push_body(&mut lines, &format!("{} ", indent));
|
|
lines.push(format!("{}</div>", indent));
|
|
} else {
|
|
push_body(&mut lines, indent);
|
|
}
|
|
lines
|
|
}
|
|
|
|
/// JS: reindentContent(contentLines, fromIndent, toIndent)
|
|
fn reindent_content(content: &[String], from_indent: &str, to_indent: &str) -> Vec<String> {
|
|
content
|
|
.iter()
|
|
.map(|line| {
|
|
if trim(line).is_empty() {
|
|
return String::new();
|
|
}
|
|
if let Some(rest) = line.strip_prefix(from_indent) {
|
|
return format!("{}{}", to_indent, rest);
|
|
}
|
|
format!("{}{}", to_indent, trim_start(line))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// JS: handleAcceptUnlocked → the outcome, or Err(error). With `bake`, a
|
|
/// knob-free variant is made permanent here (see `bake.rs`); when that is
|
|
/// not mechanical the carbonize block is left as before, with the reason.
|
|
fn handle_accept_unlocked(
|
|
id: &str,
|
|
variant_num: &str,
|
|
lines: &[String],
|
|
target_file: &str,
|
|
param_values: Option<&Map<String, Value>>,
|
|
bake: Option<&BakeRequest>,
|
|
) -> Result<AcceptOutcome, String> {
|
|
let Some(block) = find_marker_block(id, lines) else {
|
|
return Err("Markers not found".to_string());
|
|
};
|
|
let cs = comment_syntax_for(target_file);
|
|
let is_jsx = cs.0 == "{/*";
|
|
let (rs, re) = expand_replace_range(&block, lines, is_jsx, id);
|
|
let indent = leading_ws(&lines[rs]);
|
|
let Some(variant_content) = extract_variant(lines, &block, variant_num) else {
|
|
return Err(format!("Variant {} not found", variant_num));
|
|
};
|
|
let original_content = extract_original(lines, &block);
|
|
let css_content = extract_css(lines, &block, id);
|
|
let variant_text = variant_content.join("\n");
|
|
let has_helper_attrs = variant_text.contains("data-impeccable-variant");
|
|
let needs_carbonize = css_content.is_some() || has_helper_attrs;
|
|
let restored = deindent_content(&variant_content, &indent);
|
|
let mut bake_skipped: Option<String> = None;
|
|
if let Some(req) = bake {
|
|
let mut unwrapped: Vec<String> = Vec::new();
|
|
unwrapped.extend_from_slice(&lines[..rs]);
|
|
unwrapped.extend(restored.iter().cloned());
|
|
unwrapped.extend_from_slice(&lines[(re + 1).min(lines.len())..]);
|
|
let source_after = unwrapped.join("\n");
|
|
let planned = crate::bake::plan(
|
|
&req.cwd,
|
|
target_file,
|
|
is_jsx,
|
|
variant_num,
|
|
css_content.as_deref(),
|
|
&restored,
|
|
param_values,
|
|
req.element.as_ref(),
|
|
&source_after,
|
|
);
|
|
match planned {
|
|
Ok(plan) => match crate::bake::apply(&plan, &req.session_id, variant_num, target_file, &source_after) {
|
|
Ok(_) => {
|
|
return Ok(AcceptOutcome::Baked {
|
|
original: original_content.join("\n"),
|
|
css_file: plan.css_file.clone(),
|
|
rules: plan.rules,
|
|
anchor: plan.anchor.clone(),
|
|
});
|
|
}
|
|
Err(e) => bake_skipped = Some(e),
|
|
},
|
|
Err(e) => bake_skipped = Some(e),
|
|
}
|
|
}
|
|
let replacement = build_carbonize_replacement(
|
|
&indent,
|
|
cs,
|
|
is_jsx,
|
|
id,
|
|
variant_num,
|
|
css_content.as_deref(),
|
|
param_values,
|
|
&restored,
|
|
);
|
|
let mut new_lines: Vec<String> = Vec::new();
|
|
new_lines.extend_from_slice(&lines[..rs]);
|
|
new_lines.extend(replacement);
|
|
new_lines.extend_from_slice(&lines[(re + 1).min(lines.len())..]);
|
|
let _ = std::fs::write(target_file, new_lines.join("\n"));
|
|
Ok(AcceptOutcome::Carbonized {
|
|
carbonize: needs_carbonize,
|
|
original: original_content.join("\n"),
|
|
bake_skipped,
|
|
})
|
|
}
|
|
|
|
/// JS: readSourceShadowPreviewMeta(content, id) → whether the wrapper carries
|
|
/// `data-impeccable-preview="source-shadow"` (the only bit accept acts on).
|
|
fn read_source_shadow_preview_meta(content: &str, id: &str) -> bool {
|
|
// /<[^>]+data-impeccable-variants=(["'])ID\1[^>]*>/
|
|
let chars: Vec<char> = content.chars().collect();
|
|
let needle: Vec<char> = "data-impeccable-variants=".chars().collect();
|
|
let mut i = 0;
|
|
let mut tag: Option<String> = None;
|
|
while i + needle.len() <= chars.len() {
|
|
if chars[i..i + needle.len()] == needle[..] {
|
|
let q_at = i + needle.len();
|
|
let idc: Vec<char> = id.chars().collect();
|
|
if let Some(&q) = chars.get(q_at) {
|
|
if (q == '"' || q == '\'')
|
|
&& q_at + 1 + idc.len() < chars.len()
|
|
&& chars[q_at + 1..q_at + 1 + idc.len()] == idc[..]
|
|
&& chars[q_at + 1 + idc.len()] == q
|
|
{
|
|
// Walk back to the nearest `<` with no `>` between; forward to `>`.
|
|
let mut s = i;
|
|
let mut ok = false;
|
|
while s > 0 {
|
|
s -= 1;
|
|
if chars[s] == '>' {
|
|
break;
|
|
}
|
|
if chars[s] == '<' {
|
|
ok = s + 1 < i;
|
|
break;
|
|
}
|
|
}
|
|
if ok {
|
|
let mut e = q_at + 2 + idc.len();
|
|
while e < chars.len() && chars[e] != '>' {
|
|
e += 1;
|
|
}
|
|
if e < chars.len() {
|
|
tag = Some(chars[s..=e].iter().collect());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
let Some(tag) = tag else { return false };
|
|
let preview = read_html_attr(&tag, "data-impeccable-preview");
|
|
if preview.as_deref() != Some("source-shadow") {
|
|
return false;
|
|
}
|
|
let source_file = read_html_attr(&tag, "data-impeccable-source-file");
|
|
let start = read_html_attr(&tag, "data-impeccable-source-start")
|
|
.map(|s| impeccable_core::js::string_to_number(&s))
|
|
.unwrap_or(0.0);
|
|
let end = read_html_attr(&tag, "data-impeccable-source-end")
|
|
.map(|s| impeccable_core::js::string_to_number(&s))
|
|
.unwrap_or(0.0);
|
|
let sf_ok = source_file.map(|s| !s.is_empty()).unwrap_or(false);
|
|
sf_ok && start.is_finite() && end.is_finite()
|
|
}
|
|
|
|
/// JS: readHtmlAttr(tag, name): `/\s<name>\s*=\s*(["'])(.*?)\1/`
|
|
fn read_html_attr(tag: &str, name: &str) -> Option<String> {
|
|
let chars: Vec<char> = tag.chars().collect();
|
|
let needle: Vec<char> = name.chars().collect();
|
|
let mut i = 0;
|
|
while i + 1 + needle.len() <= chars.len() {
|
|
if impeccable_core::js::is_js_whitespace(chars[i])
|
|
&& chars[i + 1..i + 1 + needle.len()] == needle[..]
|
|
{
|
|
let mut j = i + 1 + needle.len();
|
|
while j < chars.len() && impeccable_core::js::is_js_whitespace(chars[j]) {
|
|
j += 1;
|
|
}
|
|
if j < chars.len() && chars[j] == '=' {
|
|
j += 1;
|
|
while j < chars.len() && impeccable_core::js::is_js_whitespace(chars[j]) {
|
|
j += 1;
|
|
}
|
|
if let Some((content, _)) = crate::accept_css::quoted_lazy(&chars, j, &|_, _| true)
|
|
{
|
|
return Some(decode_html_attr(&content));
|
|
}
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
None
|
|
}
|
|
|
|
fn decode_html_attr(value: &str) -> String {
|
|
value
|
|
.replace(""", "\"")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace("&", "&")
|
|
}
|
|
|
|
/// JS: isVariantEndMarkerLine(line, id): `impeccable-variants-end\s+ID(?:\s|--|\*/|$)`
|
|
fn is_variant_end_marker_line(line: &str, id: &str) -> bool {
|
|
let re = Regex::new(&format!(
|
|
r"impeccable-variants-end{ws}+{id}(?:{ws}|--|\*/|$)",
|
|
ws = WS,
|
|
id = regex::escape(id)
|
|
))
|
|
.unwrap();
|
|
re.is_match(line)
|
|
}
|
|
|
|
/// JS: hasVariantWrapperAttr(line, id)
|
|
fn has_variant_wrapper_attr(line: &str, id: &str) -> bool {
|
|
let e = regex::escape(id);
|
|
let re = Regex::new(&format!(
|
|
r#"data-impeccable-variants{ws}*={ws}*(?:"{id}"|'{id}'|\{{["']{id}["']\}})"#,
|
|
ws = WS,
|
|
id = e
|
|
))
|
|
.unwrap();
|
|
re.is_match(line)
|
|
}
|
|
|
|
static DIV_OPEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"<div(?-u:\b)").unwrap());
|
|
static DIV_TAG_RE: Lazy<Regex> =
|
|
Lazy::new(|| Regex::new(&format!(r"<div(?-u:\b)[^>]*?(/?)>|</div{}*>", WS)).unwrap());
|
|
|
|
/// JS: expandReplaceRange(block, lines, isJsx) → (start, end)
|
|
fn expand_replace_range(
|
|
block: &MarkerBlock,
|
|
lines: &[String],
|
|
is_jsx: bool,
|
|
id: &str,
|
|
) -> (usize, usize) {
|
|
if !is_jsx {
|
|
return (block.start, block.end);
|
|
}
|
|
let mut start = block.start;
|
|
let mut end = block.end;
|
|
let mut i = block.start as i64 - 1;
|
|
while i >= 0 {
|
|
let iu = i as usize;
|
|
if is_variant_end_marker_line(&lines[iu], id) {
|
|
break;
|
|
}
|
|
if has_variant_wrapper_attr(&lines[iu], id) {
|
|
let mut opener = iu;
|
|
while opener > 0
|
|
&& !DIV_OPEN_RE.is_match(&lines[opener])
|
|
&& !is_variant_end_marker_line(&lines[opener], id)
|
|
{
|
|
opener -= 1;
|
|
}
|
|
if DIV_OPEN_RE.is_match(&lines[opener]) {
|
|
start = opener;
|
|
}
|
|
break;
|
|
}
|
|
i -= 1;
|
|
}
|
|
let joined = lines[start..].join("\n");
|
|
let mut depth: i64 = 0;
|
|
for m in DIV_TAG_RE.captures_iter(&joined) {
|
|
let whole = m.get(0).unwrap();
|
|
let is_close = whole.as_str().starts_with("</");
|
|
let is_self_close = !is_close && m.get(1).map(|g| g.as_str() == "/").unwrap_or(false);
|
|
if is_close {
|
|
depth -= 1;
|
|
} else if !is_self_close {
|
|
depth += 1;
|
|
}
|
|
if depth <= 0 {
|
|
let lines_before = joined[..whole.end()].matches('\n').count();
|
|
let candidate_end = start + lines_before;
|
|
if candidate_end >= end {
|
|
end = candidate_end;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
(start, end)
|
|
}
|
|
|
|
static STYLE_FULL_RE: Lazy<Regex> =
|
|
Lazy::new(|| Regex::new(&format!(r"(?s)<style(?-u:\b)[^>]*>.*?</style{}*>", WS)).unwrap());
|
|
static STYLE_SELF_RE: Lazy<Regex> =
|
|
Lazy::new(|| Regex::new(&format!(r"<style(?-u:\b)[^>]*/{}*>", WS)).unwrap());
|
|
static STYLE_OPEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"<style(?-u:\b)").unwrap());
|
|
static STYLE_CLOSE_RE: Lazy<Regex> =
|
|
Lazy::new(|| Regex::new(&format!(r"</style{}*>", WS)).unwrap());
|
|
|
|
/// JS: stripStyleAndJoin(lines, block)
|
|
fn strip_style_and_join(lines: &[String], block: &MarkerBlock) -> String {
|
|
let mut out: Vec<String> = Vec::new();
|
|
let mut in_style = false;
|
|
for line in &lines[block.start..=block.end.min(lines.len() - 1)] {
|
|
if !in_style {
|
|
let a = STYLE_FULL_RE.replace_all(line, "");
|
|
let mut l = STYLE_SELF_RE.replace_all(&a, "").into_owned();
|
|
if let Some(m) = STYLE_OPEN_RE.find(&l) {
|
|
l = l[..m.start()].to_string();
|
|
in_style = true;
|
|
}
|
|
out.push(l);
|
|
} else if let Some(m) = STYLE_CLOSE_RE.find(line) {
|
|
in_style = false;
|
|
let rest = &line[m.start()..];
|
|
out.push(STYLE_CLOSE_RE.replacen(rest, 1, "").into_owned());
|
|
}
|
|
}
|
|
out.join("\n")
|
|
}
|
|
|
|
/// JS: extractInnerByAttr(text, attrMatch) with `attrMatch` a literal.
|
|
fn extract_inner_by_attr(text: &str, attr: &str) -> Option<String> {
|
|
// /<([A-Za-z][A-Za-z0-9]*)\b[^>]*ATTR[^>]*>/
|
|
let opener_re = Regex::new(&format!(
|
|
r"<([A-Za-z][A-Za-z0-9]*)(?-u:\b)[^>]*{}[^>]*>",
|
|
regex::escape(attr)
|
|
))
|
|
.ok()?;
|
|
let m = opener_re.captures(text)?;
|
|
let tag_name = m[1].to_string();
|
|
let whole = m.get(0).unwrap();
|
|
let inner_start = whole.end();
|
|
let tag_re = Regex::new(&format!(
|
|
r"<(?:/)?{}(?-u:\b)[^>]*>",
|
|
regex::escape(&tag_name)
|
|
))
|
|
.ok()?;
|
|
let self_close_re = Regex::new(&format!(r"/{}*>$", WS)).unwrap();
|
|
let mut depth = 1i64;
|
|
for t in tag_re.find_iter(&text[inner_start..]) {
|
|
let s = t.as_str();
|
|
let is_close = s.starts_with("</");
|
|
let is_self_close = !is_close && self_close_re.is_match(s);
|
|
if is_close {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
return Some(text[inner_start..inner_start + t.start()].to_string());
|
|
}
|
|
} else if !is_self_close {
|
|
depth += 1;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Drop leading and trailing blank lines while keeping a single empty line
|
|
/// when the inner text is only whitespace.
|
|
fn trim_surrounding_blank_lines(lines: Vec<String>) -> Vec<String> {
|
|
let mut start = 0usize;
|
|
let mut end = lines.len();
|
|
while end - start > 1 && trim(&lines[start]).is_empty() {
|
|
start += 1;
|
|
}
|
|
while end - start > 1 && trim(&lines[end - 1]).is_empty() {
|
|
end -= 1;
|
|
}
|
|
lines[start..end].to_vec()
|
|
}
|
|
|
|
/// JS: extractOriginal(lines, block)
|
|
fn extract_original(lines: &[String], block: &MarkerBlock) -> Vec<String> {
|
|
let text = strip_style_and_join(lines, block);
|
|
match extract_inner_by_attr(&text, "data-impeccable-variant=\"original\"") {
|
|
None => Vec::new(),
|
|
Some(inner) => trim_surrounding_blank_lines(inner.split('\n').map(String::from).collect()),
|
|
}
|
|
}
|
|
|
|
/// JS: extractVariant(lines, block, variantNum)
|
|
fn extract_variant(
|
|
lines: &[String],
|
|
block: &MarkerBlock,
|
|
variant_num: &str,
|
|
) -> Option<Vec<String>> {
|
|
let text = strip_style_and_join(lines, block);
|
|
let inner = extract_inner_by_attr(
|
|
&text,
|
|
&format!("data-impeccable-variant=\"{}\"", variant_num),
|
|
)?;
|
|
let result = trim_surrounding_blank_lines(inner.split('\n').map(String::from).collect());
|
|
if result.is_empty() {
|
|
None
|
|
} else {
|
|
Some(result)
|
|
}
|
|
}
|
|
|
|
static STYLE_SAME_LINE_RE: Lazy<Regex> =
|
|
Lazy::new(|| Regex::new(&format!(r"(?s)<style(?-u:\b)[^>]*>(.*?)</style{}*>", WS)).unwrap());
|
|
|
|
/// JS: extractCss(lines, block, id)
|
|
fn extract_css(lines: &[String], block: &MarkerBlock, id: &str) -> Option<Vec<String>> {
|
|
let style_attr = format!("data-impeccable-css=\"{}\"", id);
|
|
let mut in_style = false;
|
|
let mut content: Vec<String> = Vec::new();
|
|
for line in &lines[block.start..=block.end.min(lines.len() - 1)] {
|
|
if !in_style && line.contains(&style_attr) {
|
|
if STYLE_SELF_RE.is_match(line) {
|
|
return None;
|
|
}
|
|
if let Some(c) = STYLE_SAME_LINE_RE.captures(line) {
|
|
let inner = strip_jsx_template_wrap(&c[1]);
|
|
return if inner.is_empty() {
|
|
None
|
|
} else {
|
|
Some(inner.split('\n').map(String::from).collect())
|
|
};
|
|
}
|
|
in_style = true;
|
|
continue;
|
|
}
|
|
if in_style {
|
|
if line.contains("</style>") {
|
|
break;
|
|
}
|
|
content.push(line.clone());
|
|
}
|
|
}
|
|
if content.is_empty() {
|
|
return None;
|
|
}
|
|
strip_jsx_template_lines(&content)
|
|
}
|
|
|
|
/// JS: stripJsxTemplateLines(content)
|
|
fn strip_jsx_template_lines(content: &[String]) -> Option<Vec<String>> {
|
|
let mut out: Vec<String> = content.to_vec();
|
|
while out.first().map(|l| trim(l).is_empty()).unwrap_or(false) {
|
|
out.remove(0);
|
|
}
|
|
while out.last().map(|l| trim(l).is_empty()).unwrap_or(false) {
|
|
out.pop();
|
|
}
|
|
if out.is_empty() {
|
|
return None;
|
|
}
|
|
let first_trim = trim_start(&out[0]).to_string();
|
|
if first_trim == "{`" {
|
|
out.remove(0);
|
|
} else if first_trim.starts_with("{`") {
|
|
let idx = out[0].find("{`").unwrap();
|
|
out[0] = format!("{}{}", &out[0][..idx], &out[0][idx + 2..]);
|
|
if trim(&out[0]).is_empty() {
|
|
out.remove(0);
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
return None;
|
|
}
|
|
let last_idx = out.len() - 1;
|
|
let last_trim = out[last_idx]
|
|
.trim_end_matches(impeccable_core::js::is_js_whitespace)
|
|
.to_string();
|
|
if last_trim == "`}" {
|
|
out.pop();
|
|
} else if last_trim.ends_with("`}") {
|
|
let text = out[last_idx].clone();
|
|
let idx = text.rfind("`}").unwrap();
|
|
out[last_idx] = format!("{}{}", &text[..idx], &text[idx + 2..]);
|
|
if trim(&out[last_idx]).is_empty() {
|
|
out.pop();
|
|
}
|
|
}
|
|
if out.is_empty() {
|
|
None
|
|
} else {
|
|
Some(out)
|
|
}
|
|
}
|
|
|
|
fn strip_jsx_template_wrap(text: &str) -> String {
|
|
let lines: Vec<String> = text.split('\n').map(String::from).collect();
|
|
match strip_jsx_template_lines(&lines) {
|
|
Some(v) => v.join("\n"),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
/// JS: deindentContent(contentLines, baseIndent)
|
|
fn deindent_content(content: &[String], base_indent: &str) -> Vec<String> {
|
|
let min_indent = min_leading_spaces(content);
|
|
content
|
|
.iter()
|
|
.map(|line| {
|
|
if trim(line).is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("{}{}", base_indent, slice_chars(line, min_indent))
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// JS: findSessionFile(id, cwd) → (file, content, lines)
|
|
fn find_session_file(id: &str, cwd: &str) -> Option<(String, String, Vec<String>)> {
|
|
let skip: [&str; 5] = ["node_modules", ".git", ".impeccable", "dist", "build"];
|
|
let extensions = resolve_live_template_extensions(cwd);
|
|
let file = find_source_file(
|
|
&format!("impeccable-variants-start {}", id),
|
|
cwd,
|
|
&extensions,
|
|
&skip,
|
|
&|_| true,
|
|
)?;
|
|
let content = safe_read(&file)?;
|
|
let lines: Vec<String> = content.split('\n').map(String::from).collect();
|
|
Some((file, content, lines))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod discard_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn discard_restores_the_original_without_blank_lines_around_it() {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"impeccable-discard-{}-{nanos}",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let file = dir.join("Login.tsx");
|
|
let src = [
|
|
" </div>",
|
|
" <div data-impeccable-variants=\"ab12cd34\" data-impeccable-variant-count=\"3\" style={{ display: \"contents\" }}>",
|
|
" {/* impeccable-variants-start ab12cd34 */}",
|
|
" <style data-impeccable-css=\"ab12cd34\">{`",
|
|
" @scope ([data-impeccable-variant=\"1\"]) { :scope > .x { color: red; } }",
|
|
" `}</style>",
|
|
" {/* Original */}",
|
|
" <div data-impeccable-variant=\"original\">",
|
|
" <Bar title=\"Log in\" />",
|
|
" </div>",
|
|
" {/* Variants: insert below this line */}",
|
|
" <div data-impeccable-variant=\"1\">",
|
|
" <Bar title=\"Log in\" />",
|
|
" </div>",
|
|
" {/* impeccable-variants-end ab12cd34 */}",
|
|
" </div>",
|
|
" </AuthPage>",
|
|
];
|
|
let lines: Vec<String> = src.iter().map(|s| s.to_string()).collect();
|
|
let path = file.to_string_lossy().into_owned();
|
|
std::fs::write(&file, lines.join("\n")).unwrap();
|
|
handle_discard_unlocked("ab12cd34", &lines, &path).unwrap();
|
|
let out = std::fs::read_to_string(&file).unwrap();
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
assert_eq!(
|
|
out,
|
|
" </div>\n <Bar title=\"Log in\" />\n </AuthPage>"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod bake_tests {
|
|
use super::*;
|
|
use std::path::PathBuf;
|
|
|
|
const SESSION: &str = "ab12cd34";
|
|
|
|
/// The source as the generate lane's edit leaves it: the wrapper the
|
|
/// preflight wrote, three variants, one preview stylesheet.
|
|
fn app_jsx() -> String {
|
|
let variant = |n: &str, hidden: bool| {
|
|
let style = if hidden { " style={{ display: 'none' }}" } else { "" };
|
|
format!(
|
|
" <div data-impeccable-variant=\"{n}\"{style}>\n <div className=\"pricing-grid\">\n <article className=\"pricing-card\">Starter</article>\n </div>\n </div>\n"
|
|
)
|
|
};
|
|
format!(
|
|
"export default function App() {{\n return (\n <main>\n <section className=\"pricing\" id=\"pricing\">\n <h2 className=\"pricing-title\">Simple pricing</h2>\n <div data-impeccable-variants=\"{s}\" data-impeccable-variant-count=\"3\" style={{{{ display: \"contents\" }}}}>\n {{/* impeccable-variants-start {s} */}}\n {{/* Original */}}\n <div data-impeccable-variant=\"original\">\n <div className=\"pricing-grid\">\n <article className=\"pricing-card\">Starter</article>\n </div>\n </div>\n {{/* Variants: insert below this line */}}\n <style data-impeccable-css=\"{s}\">{{`\n @scope ([data-impeccable-variant=\"1\"]) {{ :scope > .pricing-grid {{ gap: 8px; }} }}\n @scope ([data-impeccable-variant=\"2\"]) {{\n :scope > .pricing-grid {{ gap: 32px; }}\n :scope .pricing-card {{ border: 2px solid #111; }}\n }}\n @scope ([data-impeccable-variant=\"3\"]) {{ :scope > .pricing-grid {{ gap: 0; }} }}\n `}}</style>\n{v1}{v2}{v3} {{/* impeccable-variants-end {s} */}}\n </div>\n </section>\n </main>\n );\n}}\n",
|
|
s = SESSION,
|
|
v1 = variant("1", false),
|
|
v2 = variant("2", true),
|
|
v3 = variant("3", true)
|
|
)
|
|
}
|
|
|
|
/// A project whose session journal says the overlay saw the anchor
|
|
/// `div.pricing-grid` match exactly one element.
|
|
fn project(tag: &str) -> PathBuf {
|
|
project_with(tag, Some(json!({ "tagName": "div", "id": null, "classes": ["pricing-grid"], "anchor": "div.pricing-grid", "anchorMatches": 1 })))
|
|
}
|
|
|
|
fn project_with(tag: &str, element: Option<Value>) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("impeccable-accept-bake-{}-{}", tag, std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(dir.join("src")).unwrap();
|
|
std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap();
|
|
std::fs::write(dir.join("src/App.jsx"), app_jsx()).unwrap();
|
|
std::fs::write(dir.join("src/styles.css"), ".pricing-grid { display: grid; gap: 20px; }\n.pricing-card { padding: 20px; }\n").unwrap();
|
|
std::fs::write(dir.join("index.html"), "<html><body><div id=\"root\"></div></body></html>").unwrap();
|
|
std::fs::write(dir.join("package.json"), "{\"name\":\"t\"}").unwrap();
|
|
let env: Env = std::env::vars().collect();
|
|
let mut generate = json!({ "type": "generate", "id": SESSION, "count": 3, "pageUrl": "/", "action": "bolder" });
|
|
if let Some(element) = element {
|
|
generate["element"] = element;
|
|
}
|
|
crate::session::create_live_session_store(&dir.to_string_lossy(), &env, Some(SESSION))
|
|
.append_event(&generate)
|
|
.unwrap();
|
|
dir
|
|
}
|
|
|
|
fn accept(dir: &PathBuf, args: &[&str]) -> Value {
|
|
let env: Env = std::env::vars().collect();
|
|
let (mut io, captured) = Io::captured("", dir.clone(), env);
|
|
let argv: Vec<String> = args.iter().map(|a| a.to_string()).collect();
|
|
let code = run(&argv, &mut io);
|
|
drop(io);
|
|
let out = String::from_utf8_lossy(&captured.stdout.borrow()).into_owned();
|
|
let err = String::from_utf8_lossy(&captured.stderr.borrow()).into_owned();
|
|
assert_eq!(code, 0, "stdout: {out}\nstderr: {err}");
|
|
serde_json::from_str(out.trim()).unwrap_or_else(|e| panic!("{e}: {out}"))
|
|
}
|
|
|
|
#[test]
|
|
fn a_bake_makes_the_variant_permanent_and_appends_its_rules() {
|
|
let dir = project("flag");
|
|
let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]);
|
|
assert_eq!(result["handled"], json!(true), "{result}");
|
|
assert_eq!(result["baked"], json!(true), "{result}");
|
|
assert_eq!(result["carbonize"], json!(false));
|
|
assert_eq!(result["variant"], json!("2"));
|
|
assert_eq!(result["css"]["file"], json!("src/styles.css"), "{result}");
|
|
assert_eq!(result["css"]["rules"], json!(2));
|
|
assert_eq!(result["css"]["anchor"], json!("div.pricing-grid"));
|
|
assert_eq!(result["verify"]["clean"], json!(true), "{result}");
|
|
let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap();
|
|
assert!(!jsx.contains("data-impeccable"), "{jsx}");
|
|
assert!(!jsx.contains("impeccable-variants"), "{jsx}");
|
|
assert!(!jsx.contains("<style"), "{jsx}");
|
|
assert_eq!(jsx.matches("className=\"pricing-grid\"").count(), 1, "{jsx}");
|
|
// The element sits where the wrapper started, at its indentation.
|
|
assert!(jsx.contains(" <h2 className=\"pricing-title\">Simple pricing</h2>\n <div className=\"pricing-grid\">\n <article className=\"pricing-card\">Starter</article>\n </div>\n </section>"), "{jsx}");
|
|
let css = std::fs::read_to_string(dir.join("src/styles.css")).unwrap();
|
|
assert!(css.starts_with(".pricing-grid { display: grid; gap: 20px; }\n"), "existing rules untouched: {css}");
|
|
assert!(css.contains("/* impeccable generate ab12cd34: accepted variant 2 */"), "{css}");
|
|
assert!(css.contains("div.pricing-grid { gap: 32px; }"), "{css}");
|
|
assert!(css.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{css}");
|
|
assert!(!css.contains("8px") && !css.contains("gap: 0"), "other variants dropped: {css}");
|
|
assert!(!css.contains(":scope") && !css.contains("data-impeccable"), "{css}");
|
|
// Idempotent: the receipt answers a rerun.
|
|
let again = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]);
|
|
assert_eq!(again["alreadyApplied"], json!(true), "{again}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn no_session_bakes_without_the_flag_the_generate_lane_included() {
|
|
let env: Env = std::env::vars().collect();
|
|
// Plain live: no origin, no flag -> the carbonize block, as before.
|
|
let dir = project("origin");
|
|
let plain = accept(&dir, &["--id", SESSION, "--variant", "2"]);
|
|
assert_eq!(plain["carbonize"], json!(true), "{plain}");
|
|
assert!(plain.get("baked").is_none(), "{plain}");
|
|
let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap();
|
|
assert!(jsx.contains("impeccable-carbonize-start"), "{jsx}");
|
|
assert!(!std::fs::read_to_string(dir.join("src/styles.css")).unwrap().contains("32px"));
|
|
|
|
// The generate verb's session (the journal says origin agent) carbonizes
|
|
// the same way: the agent integrates the accepted variant per live.md.
|
|
let dir2 = project("origin2");
|
|
let cwd2 = dir2.to_string_lossy().into_owned();
|
|
crate::session::create_live_session_store(&cwd2, &env, Some(SESSION))
|
|
.append_event(&json!({ "type": "generate", "id": SESSION, "origin": "agent", "count": 3, "pageUrl": "/", "action": "bolder" }))
|
|
.unwrap();
|
|
let lane = accept(&dir2, &["--id", SESSION, "--variant", "2"]);
|
|
assert_eq!(lane["carbonize"], json!(true), "{lane}");
|
|
assert!(lane.get("baked").is_none(), "{lane}");
|
|
assert!(std::fs::read_to_string(dir2.join("src/App.jsx")).unwrap().contains("impeccable-carbonize-start"));
|
|
// --bake is the only way in, and --no-bake still wins over it.
|
|
let dir3 = project("origin3");
|
|
let baked = accept(&dir3, &["--id", SESSION, "--variant", "2", "--bake"]);
|
|
assert_eq!(baked["baked"], json!(true), "{baked}");
|
|
let dir4 = project("origin4");
|
|
let kept = accept(&dir4, &["--id", SESSION, "--variant", "2", "--bake", "--no-bake"]);
|
|
assert_eq!(kept["carbonize"], json!(true), "{kept}");
|
|
for d in [dir, dir2, dir3, dir4] {
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_class_anchor_the_page_showed_more_than_once_refuses_the_bake() {
|
|
// Three elements matched the anchor when Go fired (three cards from
|
|
// one JSX element, say): lasting rules on it would restyle them all.
|
|
let dir = project_with("siblings", Some(json!({ "anchor": "div.pricing-grid", "anchorMatches": 3 })));
|
|
let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]);
|
|
assert_eq!(result["carbonize"], json!(true), "{result}");
|
|
assert!(result["bakeSkipped"].as_str().unwrap().contains("div.pricing-grid matches 3 elements"), "{result}");
|
|
assert!(std::fs::read_to_string(dir.join("src/App.jsx")).unwrap().contains("impeccable-carbonize-start"));
|
|
assert!(!std::fs::read_to_string(dir.join("src/styles.css")).unwrap().contains("32px"));
|
|
// Without the overlay's descriptor there is nothing to verify against.
|
|
let dir2 = project_with("nodesc", None);
|
|
let result = accept(&dir2, &["--id", SESSION, "--variant", "2", "--bake"]);
|
|
assert_eq!(result["carbonize"], json!(true), "{result}");
|
|
assert!(result["bakeSkipped"].as_str().unwrap().contains("no element descriptor"), "{result}");
|
|
for d in [dir, dir2] {
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_knob_session_falls_back_to_the_carbonize_block_with_the_reason() {
|
|
let dir = project("knobs");
|
|
let src = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap().replace("gap: 32px", "gap: var(--p-gap, 32px)");
|
|
std::fs::write(dir.join("src/App.jsx"), src).unwrap();
|
|
let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]);
|
|
assert_eq!(result["carbonize"], json!(true), "{result}");
|
|
assert!(result["bakeSkipped"].as_str().unwrap().contains("knobs"), "{result}");
|
|
let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap();
|
|
assert!(jsx.contains("impeccable-carbonize-start"), "{jsx}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|