@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 +3 -5
- package/src/build.ts +3 -2
- package/src/css-modules.ts +106 -70
- package/src/css.ts +41 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junejs/server",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
34
|
-
"@junejs/db": "0.0.
|
|
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 =
|
|
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);
|
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`) ------------
|
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 —
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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
|
|
116
|
-
//
|
|
117
|
-
|
|
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
|
}
|