@junejs/server 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "June host adapters + dev server. Host-coupled; depends on the pure @junejs/core contract layer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,12 +30,10 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.18",
34
- "@junejs/db": "0.0.18",
33
+ "@junejs/core": "0.0.20",
34
+ "@junejs/db": "0.0.20",
35
35
  "marked": "^18.0.5",
36
36
  "rolldown": "^1.1.0",
37
- "postcss": "^8.4.0",
38
- "postcss-modules": "^6.0.0",
39
37
  "lightningcss": "^1.27.0"
40
38
  },
41
39
  "peerDependencies": {
package/src/build.ts CHANGED
@@ -34,7 +34,7 @@ import { createWorker, type WorkerManifest } from "./worker";
34
34
  import { findExtraFile } from "./router";
35
35
  import type { ExtraHandler, LayoutComponent, LoadingComponent } from "./pipeline";
36
36
  import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
37
- import { findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
37
+ import { cssTargets, findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
38
38
  import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
39
39
 
40
40
  export type BuildResult = {
@@ -290,7 +290,8 @@ export async function juneBuild(
290
290
  const { maps: cssModuleMaps, css: rawModuleCss } = await buildModuleCss(appDir, appRoot);
291
291
  // Minify for build (dev serves it readable). Scoped class names are untouched,
292
292
  // so the hashed sheet still matches the maps the bundlers/loaders hand out.
293
- const moduleCss = rawModuleCss === null ? null : await minifyCss(rawModuleCss, "modules.css");
293
+ const moduleCss =
294
+ rawModuleCss === null ? null : await minifyCss(rawModuleCss, "modules.css", await cssTargets(appDir));
294
295
  let moduleCssAsset: string | null = null;
295
296
  if (moduleCss !== null) {
296
297
  const hash = createHash("sha256").update(moduleCss).digest("hex").slice(0, 8);
@@ -3,22 +3,21 @@
3
3
  //
4
4
  // The hard constraint: the scoped names must be byte-identical in dev SSR
5
5
  // (un-bundled import), the worker build, and the client build (hydration must
6
- // match SSR). We guarantee that by NEVER deriving the name from a bundler
7
- // internal — `scopedName(stableKey, class)` is a pure hash of an app-relative
8
- // path + the class. One main-thread pass globs + transforms every .module.css
9
- // into a maps dict; the Bun plugin / Node loader / rolldown plugin are then dumb
10
- // lookups into it, and the served/emitted CSS comes from the SAME pass — so the
11
- // JS maps and the CSS agree by construction.
6
+ // match SSR). One main-thread pass globs + transforms every .module.css into a
7
+ // maps dict; the Bun plugin / Node loader / rolldown plugin are then dumb lookups
8
+ // into it, and the served/emitted CSS comes from the SAME pass — so the JS maps
9
+ // and the CSS agree by construction. Names stay machine-independent because the
10
+ // transform is keyed on an APP-RELATIVE path (stableKey), not an absolute one.
12
11
  //
13
- // Transforms go through postcss-modules, so the full CSS-Modules surface works:
14
- // local scoping (the default), `:global(...)`, and cross-file `composes`. The one
15
- // thing we override is generateScopedName — it's our deterministic hash, not a
16
- // bundler internal, which is what makes dev/build/client/Node agree.
12
+ // Engine: Lightning CSS (Rust) — local scoping (default), `:global(...)`, and
13
+ // cross-file `composes`. Unlike postcss-modules, Lightning resolves `composes` by
14
+ // REFERENCE (it records the composed scoped name; it does NOT inline the rule), so
15
+ // a class composed across N files lands ONCE in the sheet with no dedup pass.
16
+ // Lightning is imported lazily and only runs at build/dev time, so it never enters
17
+ // the worker graph.
17
18
 
18
- import { createHash } from "node:crypto";
19
19
  import { readFile, readdir } from "node:fs/promises";
20
- import { join, relative, sep } from "node:path";
21
- import type { AcceptedPlugin, ChildNode } from "postcss";
20
+ import { dirname, join, relative, resolve as resolvePath, sep } from "node:path";
22
21
 
23
22
  export const MODULE_STYLES_URL = "/_june/modules.css";
24
23
 
@@ -29,35 +28,89 @@ export function stableKey(appRoot: string, file: string): string {
29
28
  return relative(appRoot, file).split(sep).join("/");
30
29
  }
31
30
 
32
- function scopedName(key: string, cls: string): string {
33
- const h = createHash("sha256").update(`${key}|${cls}`).digest("hex").slice(0, 8);
34
- return `${cls}_${h}`;
31
+ // One `composes` entry, as Lightning reports it: a class in this file (local, already
32
+ // scoped), a global class (unscoped, applied as-is), or a class in another module
33
+ // file (dependency — resolved against that file's exports).
34
+ type Compose =
35
+ | { type: "local"; name: string }
36
+ | { type: "global"; name: string }
37
+ | { type: "dependency"; name: string; specifier: string };
38
+ type ModuleExports = Record<string, { name: string; composes: Compose[] }>;
39
+ type Transformed = { css: string; exports: ModuleExports };
40
+
41
+ // Lightning-transform ONE file: deterministic scoped CSS + its exports. Cross-file
42
+ // `composes` come back as unresolved `dependency` refs — the caller resolves them.
43
+ async function transformOne(file: string, appRoot: string): Promise<Transformed> {
44
+ const { transform } = await import("lightningcss");
45
+ const { code, exports } = transform({
46
+ // The STABLE app-relative path drives Lightning's [hash], so names are
47
+ // machine-independent and identical across dev / worker build / client build.
48
+ filename: stableKey(appRoot, file),
49
+ code: Buffer.from(await readFile(file, "utf8")),
50
+ cssModules: { pattern: "[local]_[hash]" },
51
+ });
52
+ return { css: code.toString(), exports: (exports ?? {}) as ModuleExports };
53
+ }
54
+
55
+ // Transform `entry` and (recursively) every file it composes from, into `all`.
56
+ async function transformGraph(
57
+ entry: string,
58
+ appRoot: string,
59
+ all: Record<string, Transformed> = {},
60
+ ): Promise<Record<string, Transformed>> {
61
+ if (all[entry]) return all;
62
+ const t = await transformOne(entry, appRoot);
63
+ all[entry] = t;
64
+ for (const exp of Object.values(t.exports)) {
65
+ for (const c of exp.composes) {
66
+ if (c.type === "dependency") {
67
+ await transformGraph(resolvePath(dirname(entry), c.specifier), appRoot, all);
68
+ }
69
+ }
70
+ }
71
+ return all;
72
+ }
73
+
74
+ // The space-joined scoped class string for one local name, following composes:
75
+ // local → already scoped · global → as-is · dependency → the target file's name.
76
+ function resolveName(
77
+ file: string,
78
+ local: string,
79
+ all: Record<string, ModuleExports>,
80
+ seen = new Set<string>(),
81
+ ): string {
82
+ const exp = all[file]?.[local];
83
+ if (!exp) return local; // not a module class (shouldn't happen) → pass through
84
+ const parts = [exp.name];
85
+ for (const c of exp.composes) {
86
+ if (c.type === "dependency") {
87
+ const target = resolvePath(dirname(file), c.specifier);
88
+ const k = `${target}::${c.name}`;
89
+ if (seen.has(k)) continue; // guard against circular composes
90
+ seen.add(k);
91
+ parts.push(resolveName(target, c.name, all, seen));
92
+ } else {
93
+ parts.push(c.name); // local (already scoped) or global (unscoped)
94
+ }
95
+ }
96
+ return parts.join(" ");
35
97
  }
36
98
 
37
- // Transform one .module.css via postcss-modules — correct scoping, `:global`,
38
- // `composes` (cross-file), and url()/string safety — but with OUR deterministic
39
- // generateScopedName, so dev SSR / worker build / client build / Node all produce
40
- // identical names. postcss is imported lazily and only runs at build/dev time, so
41
- // it never enters the worker graph.
99
+ const exportsOnly = (all: Record<string, Transformed>): Record<string, ModuleExports> =>
100
+ Object.fromEntries(Object.entries(all).map(([f, t]) => [f, t.exports]));
101
+
102
+ // Transform one .module.css (+ its composes deps) → its class map and scoped CSS.
103
+ // The CSS is THIS file's rules only; composed rules live in their own files (and so
104
+ // appear once in the combined sheet), never inlined here.
42
105
  export async function transformCssModule(
43
106
  file: string,
44
107
  appRoot: string,
45
108
  ): Promise<{ map: Record<string, string>; css: string }> {
46
- const postcss = (await import("postcss")).default;
47
- const postcssModules = (await import("postcss-modules")).default as (opts: {
48
- generateScopedName(name: string, filename: string): string;
49
- getJSON(file: string, json: Record<string, string>): void;
50
- }) => AcceptedPlugin;
51
- let map: Record<string, string> = {};
52
- const result = await postcss([
53
- postcssModules({
54
- generateScopedName: (name, filename) => scopedName(stableKey(appRoot, filename), name),
55
- getJSON: (_f, json) => {
56
- map = json;
57
- },
58
- }),
59
- ]).process(await readFile(file, "utf8"), { from: file });
60
- return { map, css: result.css };
109
+ const all = await transformGraph(file, appRoot);
110
+ const exports = exportsOnly(all);
111
+ const map: Record<string, string> = {};
112
+ for (const local of Object.keys(all[file]!.exports)) map[local] = resolveName(file, local, exports);
113
+ return { map, css: all[file]!.css };
61
114
  }
62
115
 
63
116
  // The JS a .module.css import resolves to.
@@ -85,50 +138,33 @@ async function findModuleCss(dir: string): Promise<string[]> {
85
138
  return out.sort();
86
139
  }
87
140
 
88
- // Drop byte-identical top-level rules. postcss-modules has no module graph, so
89
- // `composes: x from "./y"` INLINES y's rule into this file's output — and we also
90
- // emit y directly, so a composed rule lands N+1 times. Vite never duplicates
91
- // (composes is a graph edge there); we get the same one-copy result by deduping.
92
- //
93
- // Two safety points:
94
- // - Exact-string match only. Our scoped names are deterministic, so the copies
95
- // are byte-identical; we never collapse two genuinely different rules.
96
- // - Keep the LAST occurrence, not the first. With :global, two files can write
97
- // the same raw selector with different bodies, so the cascade can interleave
98
- // (red, blue, red). The winner is the last declaration; dropping the EARLIER
99
- // identical copies leaves the last one in place, so the computed result is
100
- // unchanged. Keeping the first instead would change which rule wins.
101
- async function dedupeRules(css: string): Promise<string> {
102
- const postcss = (await import("postcss")).default;
103
- const root = postcss.parse(css);
104
- const last = new Map<string, ChildNode>();
105
- root.each((node) => {
106
- if (node.type === "rule" || node.type === "atrule") last.set(node.toString(), node);
107
- });
108
- root.each((node) => {
109
- if ((node.type === "rule" || node.type === "atrule") && last.get(node.toString()) !== node) {
110
- node.remove();
111
- }
112
- });
113
- return root.toString();
114
- }
115
-
116
141
  // One pass over app/**/*.module.css → the per-file class maps AND the combined
117
142
  // scoped stylesheet. null css when there are none. Deterministic, so the maps the
118
- // loaders hand out match the CSS that's served/emitted.
143
+ // loaders hand out match the CSS that's served/emitted. composes are references
144
+ // (not inlined), so each rule appears once — no dedup pass.
119
145
  export async function buildModuleCss(
120
146
  appDir: string,
121
147
  appRoot: string,
122
148
  ): Promise<{ maps: ModuleMaps; css: string | null }> {
123
149
  const files = await findModuleCss(appDir);
150
+ if (!files.length) return { maps: {}, css: null };
151
+
152
+ const all: Record<string, Transformed> = {};
153
+ for (const file of files) await transformGraph(file, appRoot, all); // picks up out-of-glob deps too
154
+ const exports = exportsOnly(all);
155
+
124
156
  const maps: ModuleMaps = {};
125
- const parts: string[] = [];
126
157
  for (const file of files) {
127
- const { map, css } = await transformCssModule(file, appRoot);
128
- maps[file] = map;
129
- parts.push(css);
158
+ maps[file] = {};
159
+ for (const local of Object.keys(all[file]!.exports)) maps[file][local] = resolveName(file, local, exports);
130
160
  }
131
- return { maps, css: parts.length ? await dedupeRules(parts.join("\n")) : null };
161
+ // Emit each transformed file's CSS once (sorted), so a composed rule — which lives
162
+ // in its own file — appears exactly once in the combined sheet.
163
+ const css = Object.keys(all)
164
+ .sort()
165
+ .map((f) => all[f]!.css)
166
+ .join("\n");
167
+ return { maps, css };
132
168
  }
133
169
 
134
170
  // --- the three dumb interception points (all lookups into `maps`) ------------
package/src/css.ts CHANGED
@@ -15,6 +15,8 @@ import { createRequire } from "node:module";
15
15
  import { join } from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
17
 
18
+ import type { Targets } from "lightningcss"; // type-only — never enters the graph
19
+
18
20
  // Framework-emitted assets live under the reserved /_june/ prefix so they never
19
21
  // collide with — or squat on — a user's own paths (cf. Next /_next/, Astro
20
22
  // /_astro/). Build content-hashes the file under here; this is the dev URL.
@@ -39,6 +41,32 @@ function resolveFromApp(specifier: string, appDir: string): string | null {
39
41
  }
40
42
  }
41
43
 
44
+ // Browser targets for autoprefix + syntax lowering (CSS nesting, :is(), oklch,
45
+ // logical properties, …). June adds NO dep here: if the app installs `browserslist`
46
+ // its config (or the browserslist defaults) drives the targets; otherwise a baked,
47
+ // broadly-supported baseline. Override per app the standard way — a `browserslist`
48
+ // field in package.json or a .browserslistrc.
49
+ const v = (major: number, minor = 0): number => (major << 16) | (minor << 8);
50
+ const DEFAULT_TARGETS: Targets = { chrome: v(107), edge: v(107), firefox: v(104), safari: v(16) };
51
+
52
+ let targetsCache: { appDir: string; targets: Targets } | null = null;
53
+ export async function cssTargets(appDir: string): Promise<Targets> {
54
+ if (targetsCache?.appDir === appDir) return targetsCache.targets;
55
+ let targets: Targets = DEFAULT_TARGETS;
56
+ const blPath = resolveFromApp("browserslist", appDir);
57
+ if (blPath) {
58
+ try {
59
+ const browserslist = ((await import(blPath)) as { default: (q?: unknown, o?: unknown) => string[] }).default;
60
+ const { browserslistToTargets } = await import("lightningcss");
61
+ targets = browserslistToTargets(browserslist(undefined, { path: appDir }));
62
+ } catch {
63
+ /* malformed config / resolution race → keep the baked default */
64
+ }
65
+ }
66
+ targetsCache = { appDir, targets };
67
+ return targets;
68
+ }
69
+
42
70
  async function compileTailwind(
43
71
  css: string,
44
72
  file: string,
@@ -76,16 +104,17 @@ export function invalidateCss(): void {
76
104
  devCache = null;
77
105
  }
78
106
 
79
- // Minify a finished stylesheet with Lightning CSS — the same engine Tailwind v4
80
- // optimizes with, so all of June's build-time CSS comes out consistently. It only
81
- // touches whitespace/comments/redundancy, never identifiers, so scoped CSS-module
82
- // class names survive intact (hydration stays byte-identical to SSR). Lazy import:
83
- // build/dev-time only, never in the worker graph. Falls back to the input if
84
- // Lightning CSS can't parse it, so minification can never break a build.
85
- export async function minifyCss(css: string, filename = "input.css"): Promise<string> {
107
+ // Minify a finished stylesheet with Lightning CSS — and, given `targets`, ALSO
108
+ // autoprefix + lower modern syntax (nesting, :is(), oklch, logical props) for the
109
+ // target browsers. The same engine Tailwind v4 optimizes with, so all of June's
110
+ // build-time CSS comes out consistently. It never touches identifiers, so scoped
111
+ // CSS-module class names survive intact (hydration stays byte-identical to SSR).
112
+ // Lazy import: build/dev-time only, never in the worker graph. Falls back to the
113
+ // input if Lightning CSS can't parse it, so it can never break a build.
114
+ export async function minifyCss(css: string, filename = "input.css", targets?: Targets): Promise<string> {
86
115
  try {
87
116
  const { transform } = await import("lightningcss");
88
- const { code } = transform({ filename, code: Buffer.from(css), minify: true });
117
+ const { code } = transform({ filename, code: Buffer.from(css), minify: true, targets });
89
118
  return code.toString();
90
119
  } catch (e) {
91
120
  console.warn("[june] CSS minify failed; emitting unminified:", e);
@@ -112,7 +141,8 @@ export async function processCss(
112
141
  "[june] global.css uses Tailwind but @tailwindcss/postcss isn't installed — serving raw CSS.",
113
142
  );
114
143
  }
115
- // Plain CSS (or the Tailwind fallback): minify here so the non-Tailwind path
116
- // is optimized too, matching the Tailwind path.
117
- return opts.minify ? await minifyCss(css, file) : css;
144
+ // Plain CSS (or the Tailwind fallback): minify + autoprefix/lower for the app's
145
+ // browser targets, so the non-Tailwind path is optimized too. (Tailwind v4 owns
146
+ // its own targeting; dev stays readable and untargeted.)
147
+ return opts.minify ? await minifyCss(css, file, await cssTargets(appDir)) : css;
118
148
  }