Skip to content

Commit 579f5e8

Browse files
committed
feat(config-generator): show placeholder preview for invalid inputs
Keep the preview visible when validation fails: invalid field values are rendered as <placeholder> next to the warnings instead of hiding the whole preview. Export keeps the strict projection, so copy and download stay disabled until every field is valid. Assisted-by: opencode:deepseek-v4-flash
1 parent a2779d6 commit 579f5e8

9 files changed

Lines changed: 178 additions & 19 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ npm ci --prefix config-generator
1818
npm run dev --prefix config-generator
1919
```
2020

21-
Open `http://127.0.0.1:8080/`. No Python or Zensical server is needed. Its application-schema selector currently provides independent TUIC server and client generators, covering multiple users, three certificate modes, SOCKS5 authentication, logging, connections, TCP/UDP forwarding, Quinn/quiche backends, outbound and ACL routing, DNS/GeoData, RESTful management, and HTTP/3 masquerading. The page has two regions: a selection region with the generator form, and a preview region that renders the live generated config with syntax highlighting, per-line XML explanations on hover or keyboard focus, validation, and copy/download.
21+
Open `http://127.0.0.1:8080/`. No Python or Zensical server is needed. Its application-schema selector currently provides independent TUIC server and client generators, covering multiple users, three certificate modes, SOCKS5 authentication, logging, connections, TCP/UDP forwarding, Quinn/quiche backends, outbound and ACL routing, DNS/GeoData, RESTful management, and HTTP/3 masquerading. The page has two regions: a selection region with the generator form, and a preview region that renders the live generated config with syntax highlighting, per-line XML explanations on hover or keyboard focus, validation, and copy/download. Invalid inputs keep the preview visible: flagged values are shown as `<placeholder>` alongside the validation warnings, while copy and download stay disabled until every field is valid.
2222

2323
When [just](https://just.systems/) is installed, the repository root provides shortcuts:
2424

‎config-generator/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ try {
2121

2222
`schema/example.xml` 是不含 TUIC 字段的任务清单示例,具有不同的品牌、输入名、集合、三个输出和导出命令。相同 Rust/Svelte 代码直接呈现它,不需要增加分支或修改 HTML。环境变量变更和选中文件变更均会触发 Cargo 重新嵌入;这是构建时切换,网页不下载外部 XML、不读取网络配置。上面的 `--outDir` 相对 `config-generator/`,保留默认 `dist/` 和组合站点产物;最后重新生成默认 `pkg/`,避免后续 Vite 开发会话继续使用示例 WASM。
2323

24-
Svelte 通过 WASM `Engine` 提交 `set`、`set-row`、`add`、`remove`、`generate` 和 `generate-row` 操作。Rust `Session` 统一处理状态和 XML 联动,再返回已计算可见性、错误及预览的显示快照。前端不接收条件表达式,也不解释 DSL。集合行以独立字符串标识作为 Svelte 的 keyed each 键,业务字段 `id` 不受影响;复制下载另行请求原始导出文本。此界面重构不改变 DSL v5 语法。
24+
Svelte 通过 WASM `Engine` 提交 `set`、`set-row`、`add`、`remove`、`generate` 和 `generate-row` 操作。Rust `Session` 统一处理状态和 XML 联动,再返回已计算可见性、错误及预览的显示快照。前端不接收条件表达式,也不解释 DSL。集合行以独立字符串标识作为 Svelte 的 keyed each 键,业务字段 `id` 不受影响;复制下载另行请求原始导出文本。校验不通过时预览不再隐藏:无效输入值在预览中显示为 `<placeholder>`,并保留警告;导出仍走严格投影,校验通过前复制和下载保持禁用。此界面重构不改变 DSL v5 语法。
2525

2626
## DSL 概要
2727

‎config-generator/src/dsl.rs‎

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ pub use metadata::{Export, Generator, Notice, Section, Ui};
1515
pub use preview::PreviewLine;
1616
use serde_json::{Map, Value};
1717

18+
/// Display-only substitute for invalid input values in the preview. Never exported.
19+
pub const PLACEHOLDER: &str = "<placeholder>";
20+
1821
#[derive(Debug, Clone, PartialEq, Eq)]
1922
pub struct DslError(pub String);
2023
impl fmt::Display for DslError {
@@ -311,6 +314,62 @@ impl Document {
311314
self.output(&self.outputs, root, root)?
312315
.ok_or_else(|| self.outputs.error("根输出不可省略"))
313316
}
317+
/// Best-effort projection for the preview: invalid inputs become [`PLACEHOLDER`] so the
318+
/// output structure stays visible. Export always uses the strict [`Self::project`].
319+
pub fn project_preview(&self, root: &Value) -> Option<Value> {
320+
let state = self.preview_state(root);
321+
self.output_mode(&self.outputs, &state, &state, true).ok().flatten()
322+
}
323+
/// Replaces values flagged by validation with the placeholder, leaving structural fields
324+
/// (output format, mode, collection selection) untouched.
325+
fn preview_state(&self, root: &Value) -> Value {
326+
let mut state = root.clone();
327+
let errors = self.validate(root);
328+
if errors.is_empty() {
329+
return state;
330+
}
331+
let excluded: BTreeSet<&str> = self
332+
.ui
333+
.mode_field
334+
.iter()
335+
.chain(self.ui.format_field.iter())
336+
.chain(self.collections.iter().filter_map(|c| c.selected_by.as_ref()))
337+
.map(String::as_str)
338+
.collect();
339+
for path in errors.keys() {
340+
if let Some((collection, index, key)) = self.row_error_path(path) {
341+
if excluded.contains(key)
342+
|| !self
343+
.collections
344+
.iter()
345+
.any(|c| c.name == collection && c.fields.iter().any(|f| f.key == key))
346+
{
347+
continue;
348+
}
349+
if let Some(object) = state
350+
.get_mut(collection)
351+
.and_then(Value::as_array_mut)
352+
.and_then(|rows| rows.get_mut(index))
353+
.and_then(Value::as_object_mut)
354+
{
355+
object.insert(key.into(), Value::String(PLACEHOLDER.into()));
356+
}
357+
} else if !excluded.contains(path.as_str()) && self.fields.iter().any(|f| &f.key == path) {
358+
state[path.as_str()] = Value::String(PLACEHOLDER.into());
359+
}
360+
}
361+
state
362+
}
363+
fn row_error_path<'a>(&self, path: &'a str) -> Option<(&'a str, usize, &'a str)> {
364+
let mut parts = path.split('.');
365+
let collection = parts.next()?;
366+
let index = parts.next()?.parse::<usize>().ok()?;
367+
let key = parts.next()?;
368+
if parts.next().is_some() {
369+
return None;
370+
}
371+
Some((collection, index, key))
372+
}
314373
/// Renders one top-level output as annotated lines from its declared descriptions.
315374
pub fn preview_lines(&self, export: &str, value: &Value, format: &str) -> Result<Vec<PreviewLine>, DslError> {
316375
let node = self
@@ -322,6 +381,9 @@ impl Document {
322381
preview::render(node, value, &self.fields, format)
323382
}
324383
fn output(&self, node: &Element, root: &Value, row: &Value) -> Result<Option<Value>, DslError> {
384+
self.output_mode(node, root, row, false)
385+
}
386+
fn output_mode(&self, node: &Element, root: &Value, row: &Value, lenient: bool) -> Result<Option<Value>, DslError> {
325387
if !self.visible(node, root, row)? {
326388
return Ok(None);
327389
}
@@ -330,7 +392,7 @@ impl Document {
330392
"outputs" | "object" => {
331393
let mut output = Map::new();
332394
for child in &node.children {
333-
if let Some(value) = self.output(child, root, row)? {
395+
if let Some(value) = self.output_mode(child, root, row, lenient)? {
334396
output.insert(child.required("name")?.into(), value);
335397
}
336398
}
@@ -354,20 +416,30 @@ impl Document {
354416
{
355417
continue;
356418
}
357-
if let Some(value) = self.output(node.single()?, root, item)? {
419+
if let Some(value) = self.output_mode(node.single()?, root, item, lenient)? {
358420
if node.tag == "record" {
359421
let key = transform(
360422
node,
361423
lookup(node, node.required("key")?, root, item)?.clone(),
362424
node.attr("key-transform"),
363-
)?;
364-
let key = key
365-
.as_str()
366-
.filter(|s| !s.is_empty())
367-
.ok_or_else(|| node.error("映射键必须是非空字符串"))?;
368-
if record.insert(key.into(), value).is_some() {
425+
);
426+
let key = match key {
427+
Ok(key) => key,
428+
Err(_) if lenient => Value::String(PLACEHOLDER.into()),
429+
Err(error) => return Err(error),
430+
};
431+
let key = match key.as_str().filter(|s| !s.is_empty()) {
432+
Some(key) => key.to_owned(),
433+
None if lenient => PLACEHOLDER.into(),
434+
None => return Err(node.error("映射键必须是非空字符串")),
435+
};
436+
if record.contains_key(&key) {
437+
if lenient {
438+
continue;
439+
}
369440
return Err(node.error("映射键在规范化后重复"));
370441
}
442+
record.insert(key, value);
371443
} else {
372444
items.push(value);
373445
}
@@ -379,11 +451,14 @@ impl Document {
379451
Value::Array(items)
380452
}
381453
}
382-
_ => {
383-
let v = self.eval(node, root, row)?;
454+
_ => match self.eval(node, root, row).and_then(|v| {
384455
self.scalar_type(node, &v)?;
385-
v
386-
}
456+
Ok(v)
457+
}) {
458+
Ok(v) => v,
459+
Err(_) if lenient => Value::String(PLACEHOLDER.into()),
460+
Err(e) => return Err(e),
461+
},
387462
};
388463
if node.attr("omit-empty") == Some("true")
389464
&& (value.as_array().is_some_and(Vec::is_empty) || value.as_object().is_some_and(Map::is_empty))
@@ -446,7 +521,9 @@ impl Document {
446521
Value::Object(result)
447522
}
448523
_ => {
449-
self.scalar_type(node, value)?;
524+
if value.as_str() != Some(PLACEHOLDER) {
525+
self.scalar_type(node, value)?;
526+
}
450527
value.clone()
451528
}
452529
};

‎config-generator/src/session/view.rs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ impl Session {
108108
let root = &self.state.data;
109109
let errors = doc.validate(root);
110110
let selected = self.selected(requested);
111+
let format = self.format();
111112
let built = build_configs(doc, root).and_then(|configs| {
112113
let configs = if reveal {
113114
configs
@@ -119,8 +120,19 @@ impl Session {
119120
Ok((export.name.clone(), config.clone()))
120121
});
121122
let (valid, preview_lines) = match &built {
122-
Ok((name, config)) => (true, preview_lines(doc, name, config, self.format())),
123-
Err(_) => (false, Vec::new()),
123+
Ok((name, config)) => (true, preview_lines(doc, name, config, format)),
124+
// Invalid inputs no longer hide the preview: render the structure with placeholders.
125+
Err(_) => (
126+
false,
127+
selected
128+
.and_then(|export| {
129+
let configs = doc.project_preview(root)?;
130+
let configs = if reveal { configs } else { doc.redact(&configs).ok()? };
131+
Some((export.name.clone(), configs.get(&export.name)?.clone()))
132+
})
133+
.map(|(name, config)| preview_lines(doc, &name, &config, format))
134+
.unwrap_or_default(),
135+
),
124136
};
125137
Snapshot {
126138
ui: Branding {

‎config-generator/tests/decoupled.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ fn unrelated_description_drives_the_complete_engine() -> Result {
4242
state.data["first"] = "20".into();
4343
assert!(doc.validate(&state.data).contains_key("last"));
4444
assert!(build_configs(&doc, &state.data).is_err());
45+
state.data["project"] = String::new().into();
46+
assert!(doc.validate(&state.data).contains_key("project"));
47+
assert!(build_configs(&doc, &state.data).is_err());
48+
let preview = doc.project_preview(&state.data).ok_or("preview")?;
49+
assert_eq!(preview["snapshot"]["title"], "<placeholder>");
50+
assert_eq!(preview["snapshot"]["items"][0]["name"], "item");
4551
Ok(())
4652
}
4753

‎config-generator/tests/schemas.rs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,34 @@ fn split_schemas_preserve_standalone_tuic_outputs() -> Result {
6161
Ok(())
6262
}
6363

64+
#[test]
65+
fn invalid_defaults_still_render_a_placeholder_preview() -> Result {
66+
for schema in ["tuic-server", "tuic-client"] {
67+
let document = document_for(schema)?;
68+
let state = State::new(document);
69+
assert!(!document.validate(&state.data).is_empty(), "{schema}");
70+
assert!(build_configs(document, &state.data).is_err(), "{schema}");
71+
let preview = document.project_preview(&state.data).ok_or("preview")?;
72+
for export in &document.exports {
73+
let Some(config) = preview.get(export.name.as_str()) else {
74+
continue;
75+
};
76+
let json = document
77+
.preview_lines(&export.name, config, "json")?
78+
.iter()
79+
.map(|line| line.text.clone())
80+
.collect::<Vec<_>>()
81+
.join("\n");
82+
assert!(
83+
json.contains("<placeholder>"),
84+
"{schema} {}: invalid defaults should surface placeholders",
85+
export.name
86+
);
87+
}
88+
}
89+
Ok(())
90+
}
91+
6492
#[test]
6593
fn generated_tuic_configs_are_annotated_across_formats() -> Result {
6694
for (schema, required, value, marker) in [

‎config-generator/tests/session.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ fn preview_export_resets_and_conditional_output_share_one_engine() -> Result {
9696
Ok(())
9797
}
9898

99+
#[test]
100+
fn invalid_inputs_keep_preview_with_placeholders() -> Result {
101+
let mut s = Session::new(Document::parse(XML)?);
102+
s.initialize(&mut random)?;
103+
action(&mut s, json!({"type":"set", "field":"project", "value":""}))?;
104+
let view = s.snapshot("snapshot", false);
105+
assert!(!view.valid);
106+
assert!(view.errors.contains_key("project"));
107+
assert_eq!(preview_json(&view)?["title"], "<placeholder>");
108+
assert!(s.export("snapshot").is_err());
109+
Ok(())
110+
}
111+
99112
#[test]
100113
fn failed_randomness_and_invalid_actions_do_not_partially_mutate_state() -> Result {
101114
let mut s = Session::new(Document::parse(XML)?);

‎config-generator/tests/xml_dsl.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,29 @@ fn named_values_conditions_and_ipv6_endpoint() -> Result {
113113
Ok(())
114114
}
115115

116+
#[test]
117+
fn preview_uses_placeholders_for_invalid_inputs() -> Result {
118+
let source = description(
119+
r#"
120+
<field name="name" type="string" default="demo" label="名称" rule="required"/>
121+
<field name="timeout" type="string" default="30" label="超时" rule="seconds"/>
122+
"#,
123+
r#"<validators><validator name="required" kind="required" message="必填。"/><validator name="seconds" kind="integer" min="1" message="必须为正整数。"/></validators>"#,
124+
r#"<object name="out"><string name="name" from="/name"/><string name="timeout" from="/timeout" transform="integer" unit="s"/></object>"#,
125+
);
126+
let doc = Document::parse(&source)?;
127+
let mut state = doc.defaults();
128+
assert!(doc.project(&state).is_ok());
129+
state["name"] = String::new().into();
130+
state["timeout"] = "bad".into();
131+
assert!(!doc.validate(&state).is_empty());
132+
assert!(doc.project(&state).is_err());
133+
let preview = doc.project_preview(&state).ok_or("preview")?;
134+
assert_eq!(preview["out"]["name"], "<placeholder>");
135+
assert_eq!(preview["out"]["timeout"], "<placeholder>");
136+
Ok(())
137+
}
138+
116139
#[test]
117140
fn output_and_option_descriptions_annotate_the_projection() -> Result {
118141
let source = description(

‎config-generator/ui/OutputPanel.svelte‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@
5858
</label>
5959
</div>
6060
<div class="cg-errors" hidden={!errors.length}>
61-
<p>完成以下字段后即可生成:</p>
61+
<p>以下字段需要修正,预览中已用 &lt;placeholder&gt; 替代:</p>
6262
<ul>{#each errors as [key, message] (key)}
6363
<li><button type="button" onclick={() => focusError(key)}>{message}</button></li>
6464
{/each}</ul>
6565
</div>
6666
{#if view.preview_lines.length}
6767
<PreviewDocument lines={view.preview_lines} {format} />
6868
{:else}
69-
<div class="cg-code"><code>填写左侧配置,预览将在校验通过后显示。</code></div>
69+
<div class="cg-code"><code>无法生成预览,请检查配置。</code></div>
7070
{/if}
7171
<div class="cg-actions">
7272
<button type="button" disabled={!view.valid} onclick={copy}>复制配置</button>

0 commit comments

Comments
 (0)