@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.
- package/package.json +3 -5
- 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.
|
|
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.
|
|
34
|
-
"@junejs/db": "0.0.
|
|
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": {
|
package/src/css-modules.ts
CHANGED
|
@@ -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).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
128
|
-
maps[file] =
|
|
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
|
-
|
|
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`) ------------
|