@junejs/server 0.0.18 → 0.0.19

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.
Files changed (2) hide show
  1. package/package.json +3 -5
  2. package/src/css-modules.ts +106 -70
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junejs/server",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
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.19",
34
+ "@junejs/db": "0.0.19",
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": {
@@ -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`) ------------