Repository navigation
Expand file tree
/
Copy pathstudio.rs
More file actions
274 lines (248 loc) · 10.1 KB
/
Copy pathstudio.rs
File metadata and controls
274 lines (248 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
//! Studio integration facade (Milestone 2 of the engine upgrade).
//!
//! One in-process call turns a parsed program into everything the studio's
//! preview webview needs: per-route pre-rendered HTML (with `data-wf-node`
//! stamps), the CSS, the JS bundle, and the node-identity map. This is the
//! studio-mode counterpart of `wf build` — it returns Rust structs instead of
//! writing files, and always stamps node ids (studio mode) so a preview click
//! can resolve to code.
use crate::codegen::node_id::{NodeMap, build_node_map};
use crate::codegen::ssg::{SiteContext, render_page_html_studio};
use crate::codegen::{JsCodegen, generate_css_with};
use crate::config::ProjectConfig;
use crate::parser::ast::{ComponentDecl, Declaration, Program, Statement};
use std::collections::HashMap;
/// A pre-rendered page keyed by its route.
#[derive(Debug, Clone)]
pub struct CompiledPage {
/// The page route, e.g. `/` or `/about`.
pub route: String,
/// SSG-rendered HTML (element roots carry `data-wf-node`).
pub html: String,
}
/// Everything the studio preview needs from one compile.
#[derive(Debug, Clone, Default)]
pub struct CompiledSite {
/// Pre-rendered static pages (the SSG paint), one per static route.
pub pages: Vec<CompiledPage>,
/// The stylesheet (`styles.css`).
pub css: String,
/// The JS bundle (`app.js`) — reactivity, routing, and `data-wf-node` stamps.
pub js: String,
/// `node_id ↔ span ↔ path` sidecar for click-to-code and structured edits.
pub node_map: NodeMap,
/// The design tokens this compile resolved, after the baseline, the
/// project's `Theme` and any config overrides have been layered.
///
/// The studio's inspector needs the values a swatch should actually show,
/// which is not what any single one of those three sources says.
pub tokens: HashMap<String, String>,
/// The `Theme` declarations in the source, by name, with the span of each.
///
/// Enough for the studio to list the themes a project has, jump to one, and
/// know which it is previewing.
pub themes: Vec<ThemeInfo>,
/// Accessibility findings for this compile: the existing WCAG element checks
/// plus contrast ratios over the resolved tokens.
///
/// The studio can surface these against the node they belong to rather than
/// waiting for the author to read build output they may never see.
pub diagnostics: Vec<Diagnostic>,
/// Why a theme failed to resolve, if it did.
///
/// The preview falls back to the baseline rather than going blank, so
/// without this the studio would show a working page and never mention that
/// the author's theme is not the one on screen.
pub theme_error: Option<String>,
}
/// A `Theme` declaration, as the studio needs to see it.
#[derive(Debug, Clone)]
pub struct ThemeInfo {
pub name: String,
/// Token names the theme sets, in source order.
pub tokens: Vec<String>,
/// Byte offset of the declaration, for click-to-code.
pub offset: u32,
pub line: u32,
}
/// One finding, flattened for the studio: its code, what it says and where.
#[derive(Debug, Clone)]
pub struct Diagnostic {
/// The code, e.g. `A11`, `T05`, `E101`.
pub rule: String,
pub message: String,
pub file: String,
pub line: usize,
pub hint: String,
}
/// Compile a program for the studio preview: node ids stamped, SSG pages + CSS +
/// JS bundle, plus the node map.
///
/// Dynamic routes (paths containing a `:param`) are not pre-rendered — the SPA
/// runtime renders them after hydration — so they are omitted from `pages`.
pub fn compile_studio(
program: &Program,
config: &ProjectConfig,
translations: &HashMap<String, HashMap<String, String>>,
) -> CompiledSite {
// The new grammar's flags and cases are resolved onto the vocabulary the
// generators read; node ids are keyed by span, which lowering keeps.
let source_program = program;
let lowered = crate::sema::lower(program.clone());
let program = &lowered;
let node_map = build_node_map(program);
// A theme that fails to resolve must not take the preview down with it: the
// studio shows a live page while the author is still typing, so fall back to
// the baseline and let `wf build` be the one that refuses. The reason is
// handed back rather than swallowed, so the studio can say why the page does
// not look the way the source says it should.
let (mut tokens, theme_error) = match crate::themes::resolve_tokens(program, &config.theme) {
Ok(tokens) => (tokens, None),
Err(e) => (crate::themes::tokens::default_tokens(), Some(e.to_string())),
};
crate::themes::apply_motion(&mut tokens, &config.motion);
let mut css = generate_css_with(&tokens, config.theme.builtin);
if let Ok(Some(dark)) = crate::themes::resolve_dark_tokens(program, &config.theme) {
css.push_str(&crate::codegen::dark_css(&dark));
}
css.push_str(&crate::codegen::scoped_css::scoped_rules(program));
let themes: Vec<ThemeInfo> = program
.declarations
.iter()
.filter_map(|d| match d {
Declaration::Theme(t) => Some(ThemeInfo {
name: t.name.clone(),
tokens: t.tokens.iter().map(|tok| tok.name.clone()).collect(),
offset: t.span.start,
line: t.span.line,
}),
_ => None,
})
.collect();
// What `wf build` would say about the program, from the one pipeline the
// build and the editor run.
let checked = crate::diagnostics::check::check_project(&crate::diagnostics::check::Project {
program: source_program,
file_of: &|_| String::new(),
source_of: &|_| None,
dir: None,
config: Some(config),
declaration_files: &[],
scripts: &[],
stylesheets: "",
incomplete: false,
});
let mut diagnostics: Vec<Diagnostic> = checked
.diagnostics
.into_iter()
.map(|d| Diagnostic {
rule: d.code.to_string(),
message: d.message,
file: d.file,
line: d.line,
hint: d.hint.unwrap_or_default(),
})
.collect();
diagnostics.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.line.cmp(&b.line)));
let mut js_gen = JsCodegen::new();
if let Some(i18n) = &config.i18n {
js_gen.set_i18n(i18n.default_locale.clone(), translations.clone());
}
js_gen.set_ssg(true); // preview boots from the SSG paint, then hydrates
js_gen.set_env(config.public_env_values());
if !config.build.base_path.is_empty() {
js_gen.set_base_path(config.build.base_path.clone());
}
js_gen.set_app_schemes(crate::config::project::app_schemes(config));
js_gen.set_studio(node_map.clone());
let js = js_gen.generate(program);
// The shared app shell (navbar/footer around the Router), if any.
let app_body: Option<Vec<Statement>> = program.declarations.iter().find_map(|d| {
if let Declaration::App(a) = d {
Some(a.body.clone())
} else {
None
}
});
// The component library, so the static paint EXPANDS calls to user components
// instead of leaving `<!--wf-component-->` placeholders: a page built from
// components used to paint empty until JS hydrated.
let components: HashMap<String, ComponentDecl> = program
.declarations
.iter()
.filter_map(|d| match d {
Declaration::Component(c) => Some((c.name.clone(), c.clone())),
_ => None,
})
.collect();
// The program's part of each page's build-time scope, once.
let program_scope = crate::codegen::static_eval::Scope::for_program(program, &config.env);
let mut pages = Vec::new();
for decl in &program.declarations {
if let Declaration::Page(page) = decl {
if page.path.contains(':') {
continue; // dynamic route — not pre-rendered
}
let site = SiteContext {
config,
app_body: app_body.as_deref(),
translations,
components: &components,
program,
program_scope: Some(&program_scope),
page_sheets: None,
node_map: Some(&node_map),
};
let html = render_page_html_studio(page, &site, true, &node_map);
pages.push(CompiledPage {
route: page.path.clone(),
html,
});
}
}
CompiledSite {
pages,
css,
js,
node_map,
tokens,
themes,
diagnostics,
theme_error,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn program(src: &str) -> Program {
crate::syntax::parse_source(src, "<test>").expect("parse")
}
fn config() -> ProjectConfig {
serde_json::from_str(r#"{"name":"test"}"#).expect("config")
}
#[test]
fn compiles_a_page_to_a_site_with_node_map() {
let src = "page Home(path: \"/\") {\n Container {\n Heading(\"Hi\").h1\n Button(\"Go\").primary\n }\n}\n";
let prog = program(src);
let site = compile_studio(&prog, &config(), &HashMap::new());
// One static page at "/".
assert_eq!(site.pages.len(), 1);
assert_eq!(site.pages[0].route, "/");
// SSG HTML + JS both carry data-wf-node stamps; CSS is non-empty.
assert!(site.pages[0].html.contains("data-wf-node="));
assert!(site.js.contains("data-wf-node"));
assert!(!site.css.is_empty());
// The node map is populated and resolves ids to source spans.
assert!(site.node_map.len() >= 3); // Container, Heading, Button
assert!(site.node_map.info("Home:0").is_some());
}
#[test]
fn skips_dynamic_routes_from_prerender() {
let src = "page Home(path: \"/\") { Text(\"home\") }\npage User(path: \"/users/:id\") { Text(\"user\") }\n";
let prog = program(src);
let site = compile_studio(&prog, &config(), &HashMap::new());
// Only the static "/" page is pre-rendered; the :id route is skipped.
assert_eq!(site.pages.len(), 1);
assert_eq!(site.pages[0].route, "/");
}
}