diff --git a/compiler/src/app.rs b/compiler/src/app.rs index ca1f7cd..f014d01 100644 --- a/compiler/src/app.rs +++ b/compiler/src/app.rs @@ -23,7 +23,7 @@ pub fn target(name: &str) -> Target { } } -/// `{entry, css, modules: {path: {code, map}}}`, or `{diagnostics: [...]}` / `{error}`. `entry` is an absolute path. +/// `{entry, css, modules: {path: {code, map, css}}}` (the program's stylesheet, and each module's own), or `{diagnostics: [...]}` / `{error}`. `entry` is an absolute path. /// `spa`: a client-only build, checked under 08 CN-3d. pub fn app(entry: &str, target: Target, module_name: &str, spa: bool) -> Value { // module keys are POSIX paths (`modules::join_path`); Windows takes `D:/a/b.rx` as it takes `D:\a\b.rx` @@ -49,7 +49,7 @@ pub fn app(entry: &str, target: Target, module_name: &str, spa: bool) -> Value { let path = &out.paths[name]; let text = read_disk(path).unwrap_or_default(); let l = lower_view(module, path, target, module_name, Some((path, &text))).map_err(|e| json!({ "error": e }))?; - modules.insert(path.clone(), json!({ "code": l.code, "map": l.map })); + modules.insert(path.clone(), json!({ "code": l.code, "map": l.map, "css": out.module_css[name] })); } Ok(json!({ "entry": set.entry, "css": out.css, "modules": modules })) })() diff --git a/compiler/src/emit.rs b/compiler/src/emit.rs index ffc44a3..1338360 100644 --- a/compiler/src/emit.rs +++ b/compiler/src/emit.rs @@ -180,6 +180,8 @@ pub struct Compiled { pub entry: String, /// CSS-4: the modules' stylesheets, beside their JavaScript pub css: String, + /// CSS-4: each module's own stylesheet, by its output name + pub module_css: IndexMap, } /// 09 MO-1: one ES module per `.rx` module. A dependency's module is a factory over the app (`__rt.module` runs it once per @@ -197,8 +199,10 @@ pub fn compile_linked(set: &ModuleSet, extra: &[Vec], by_source: bool) -> g.by_source = by_source; let mut files = IndexMap::new(); let mut paths = IndexMap::new(); + let mut module_css = IndexMap::new(); for path in &set.order { let p = &set.programs[path]; + let css0 = g.css.len(); g.begin_module(path, &p.decls); let mut deps: Vec<&str> = vec![]; for d in &p.decls { @@ -232,10 +236,11 @@ pub fn compile_linked(set: &ModuleSet, extra: &[Vec], by_source: bool) -> fbody.append(&mut inner); body.push(S::ExportDefault(Func { name: Some("module".into()), params: vec![P::Id("__rt".into())], directives: vec![], body: fbody, is_async: false, generator: false })); } + module_css.insert(g.out(path), g.css[css0..].join("\n")); files.insert(g.out(path), body); paths.insert(g.out(path), path.clone()); } - Ok(Compiled { files, paths, entry: g.out(&set.entry), css: g.css.join("\n") }) + Ok(Compiled { files, paths, entry: g.out(&set.entry), css: g.css.join("\n"), module_css }) } struct Gen<'p> { diff --git a/compiler/test/vite.mjs b/compiler/test/vite.mjs index 44454d5..05af552 100644 --- a/compiler/test/vite.mjs +++ b/compiler/test/vite.mjs @@ -118,9 +118,51 @@ if (!filter || "shared".includes(filter)) { // two programs on one page, shari if (!filter || "todo".includes(filter)) { // the dev server: modules transformed on request const dir = project("todo-dev", "todo.rx"); const server = await vite.createServer(config(dir, "todo.rx")); await server.listen(0); - await drive("todo (dev)", `http://127.0.0.1:${server.httpServer.address().port}/`, todo); + await drive("todo (dev)", server.resolvedUrls.local[0], todo); await server.close(); } +if (!filter || "styles".includes(filter)) { // CSS-4 under the dev server: a dependency's \n

badge

\n}\n`; + writeFileSync(badge, style("rgb(0, 0, 255)")); + const server = await vite.createServer(config(dir, "styled/main.rx")); await server.listen(0); + const url = server.resolvedUrls.local[0]; + const color = page => page.$eval(".badge", e => getComputedStyle(e).color); + const until = async (page, want, what) => { + for (let i = 0; i < 40 && await color(page).catch(() => "") !== want; i++) await page.waitForTimeout(100); + const got = await color(page).catch(e => e.message.split("\n")[0]); if (got !== want) throw new Error(`${what}: color ${got}, not ${want}`); + }; + await drive("styles (dev)", url, async page => { + await until(page, "rgb(0, 0, 255)", "first load"); + writeFileSync(badge, style("rgb(255, 0, 0)")); + await until(page, "rgb(255, 0, 0)", "after the edit"); + await page.reload(); await until(page, "rgb(255, 0, 0)", "after a reload"); + const fresh = await browser.newPage(); + try { await fresh.goto(url); await until(fresh, "rgb(255, 0, 0)", "in a new page"); } finally { await fresh.close(); } + }); + await server.close(); +} +if (!filter || "cold".includes(filter)) { // a dependency asked for before its entry, as Vitest's `fsModuleCache` leaves it + const { default: rx } = await import(pathToFileURL(path.join(pkg, "vite.mjs")).href); + const root = project("cold", "shop/main.rx"), catalog = path.join(root, "src/shop/catalog.rx"); + const ctx = (id, moduleId) => ({ id, moduleId, root, ssr: false, solid: { generate: "dom", hydratable: false }, addWatchFile() {} }); + try { + // a commented-out import is not an edge: read as one, it would make main.rx look imported and leave no entry + writeFileSync(catalog, readFileSync(catalog, "utf8") + `\n// import { x } from "./main.rx"\n`); + const warm = rx(); warm.compile("", ctx(path.join(root, "src/shop/main.rx"), path.join(root, "src/shop/main.rx"))); + const want = warm.compile("", ctx(catalog, `${catalog}?rx-dep`)).code; + const got = rx().compile("", ctx(catalog, `${catalog}?rx-dep`)).code; + if (got !== want) throw new Error("the dependency compiled cold differs from the one compiled after its entry"); + writeFileSync(catalog, readFileSync(catalog, "utf8") + "\nexport bad = missingName\n"); + let report = ""; + try { rx().compile("", ctx(catalog, `${catalog}?rx-dep`)); } catch (e) { report = e.message; } + if (!/catalog\.rx:\d+(:\d+)?: error /.test(report)) throw new Error(`a cold dependency that does not compile reports ${JSON.stringify(report.slice(0, 200))}`); + pass++; console.log("✓ cold dependency: compiled from the program that holds it, or that program's diagnostics"); + } catch (e) { fail++; console.log(`✗ cold dependency: ${e.message.split("\n")[0]}`); } +} await browser.close(); console.log(`\n${pass} pass, ${fail} fail (vite ${vite.version}, @solidjs/vite-plugin ${JSON.parse(readFileSync(path.join(pluginDir, "package.json"), "utf8")).version})`); process.exit(fail ? 1 : 0); diff --git a/compiler/vite.mjs b/compiler/vite.mjs index d65161b..ed6f027 100644 --- a/compiler/vite.mjs +++ b/compiler/vite.mjs @@ -3,8 +3,8 @@ // names it explicitly. // // The crate (its Node-API build, `src/node.rs`: what `rxc app` does) already hands each module to Solid's compiler -// (spec/11 CP-1), so a `.rx` module comes back as Solid's output for the environment's `generate`, with its source map onto the `.rx` and, on a -// program's entry, the program's stylesheet. Solid's plugin then takes it the way it takes lowered `.tsrx`: primitive +// (spec/11 CP-1), so a `.rx` module comes back as Solid's output for the environment's `generate`, with its source map onto the `.rx` and +// its own stylesheet. Solid's plugin then takes it the way it takes lowered `.tsrx`: primitive // names, `lazy()`, the HMR refresh pass, server functions (12 SF-7a), the stylesheet as a CSS module. // // Entries find themselves. JavaScript imports only a program's entry (its default export mounts the app); an `.rx` @@ -13,7 +13,7 @@ // changes. The crate's output imports its dependencies as `./x.rx?rx-dep`, and a dependency is served from the build // of a program that contains it. A module several programs share is one module: its instance is keyed by its factory // (11 CP-4b `module`), not by its place in a program. -import { existsSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -35,6 +35,29 @@ const isDep = moduleId => new URLSearchParams(moduleId.split("?")[1] ?? "").has( // `import __m0 from "./catalog.rx";` → `"./catalog.rx?rx-dep"` (one line each: the module's map is by line, CP-1c) const tagDeps = code => code.replace(/^(import \w+ from ")(\.{1,2}\/[^"?]*\.rx)(";?)$/gm, `$1$2?${DEP}$3`); +const RX_IMPORT = /^import\b[^"']*?\bfrom\s+(["'])(\.{1,2}\/[^"'?]*\.rx)\1/gm; +/** The `.rx` files under `root` from which `file` is reached by imports: first those no other `.rx` file imports. */ +function entriesOf(file, root) { + const importers = new Map(); + const walk = dir => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (e.name === "node_modules" || e.name.startsWith(".")) continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".rx")) for (const [, , spec] of readFileSync(p, "utf8").matchAll(RX_IMPORT)) { + const dep = path.resolve(path.dirname(p), spec); + importers.set(dep, [...(importers.get(dep) ?? []), p]); + } + } + }; + walk(root); + const seen = new Set([file]); + for (const f of seen) for (const i of importers.get(f) ?? []) seen.add(i); + seen.delete(file); + const top = f => !importers.has(f); + return [...seen].sort().sort((a, b) => top(b) - top(a)); +} + /** @returns {import("@solidjs/vite-plugin").Frontend} */ export default function rx() { const builds = new Map(); // entry\0target\0moduleName\0spa → { entry, target, moduleName, spa, out, stamps } @@ -50,28 +73,38 @@ export default function rx() { builds.set(key, b); return b; } + const built = (file, target, moduleName, spa) => + [...builds.values()].filter(b => b.target === target && b.moduleName === moduleName && b.spa === spa && b.out.modules?.[file]); /** A dependency: from a build of a program that has it, rebuilt if that program changed. */ - function containing(file, target, moduleName, spa) { - const has = [...builds.values()].filter(b => b.target === target && b.moduleName === moduleName && b.spa === spa && b.out.modules?.[file]); + function containing(file, target, moduleName, spa, root) { + const has = built(file, target, moduleName, spa); const b = has.find(fresh) ?? has[0]; - if (!b) throw new Error(`${file} is imported as an .rx dependency, but no program built so far contains it`); - return build(b.entry, target, moduleName, spa); + if (b) return build(b.entry, target, moduleName, spa); + // the entry's compile was skipped (Vitest's `fsModuleCache` served it), so find the program from the files + let failed; + for (const entry of entriesOf(file, root)) { + const found = build(entry, target, moduleName, spa); + if (found.out.modules?.[file]) return found; + if (!failed && found.out.diagnostics) failed = found; // the program holds it but does not compile: report why + } + if (failed) return failed; + throw new Error(`${file} is imported as an .rx dependency, but no program under ${root} contains it`); } return { - compile(_source, { id, moduleId, solid, addWatchFile }) { + compile(_source, { id, moduleId, root, solid, addWatchFile }) { const file = path.resolve(id); const target = solid.generate === "ssr" ? "ssr" : solid.generate === "universal" ? "universal" : "dom"; const moduleName = solid.moduleName ?? "@solidjs/web"; // 08 CN-3d: a client-only build (no SSR, nothing hydrates) has nothing to show on a first load nothing holds const spa = target === "dom" && solid.hydratable === false; const dep = isDep(moduleId); - const { entry, out } = dep ? containing(file, target, moduleName, spa) : build(file, target, moduleName, spa); + const { out } = dep ? containing(file, target, moduleName, spa, path.resolve(root ?? process.cwd())) : build(file, target, moduleName, spa); if (out.diagnostics) throw new Error(report(out.diagnostics)); if (out.error) throw new Error(out.error); const m = out.modules[file]; for (const p of Object.keys(out.modules)) if (p !== file) addWatchFile(p); - // CSS-4: the program's stylesheet, linked once, from its entry - return { code: tagDeps(m.code), map: m.map, css: !dep && file === entry ? out.css : "" }; + // CSS-4: each module links its own stylesheet, so a module several programs share links it once + return { code: tagDeps(m.code), map: m.map, css: m.css ?? "" }; }, }; }