@junejs/server 0.0.27 → 0.0.30

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.27",
3
+ "version": "0.0.30",
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,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@junejs/core": "0.0.27",
34
- "@junejs/db": "0.0.27",
33
+ "@junejs/core": "0.0.29",
34
+ "@junejs/db": "0.0.28",
35
35
  "marked": "^18.0.5",
36
36
  "rolldown": "^1.1.0",
37
37
  "lightningcss": "^1.27.0"
package/src/app.ts CHANGED
@@ -23,7 +23,7 @@ import { createPipeline, type ExtraHandler, type LayoutComponent, type Pipeline,
23
23
  import { resolveBoundary } from "./segment";
24
24
  import { memoizeResources } from "./resources";
25
25
  import { findClientEntry, bundleClientToString, CLIENT_SCRIPT_URL } from "./client-bundle";
26
- import { findGlobalCss, processCssCached, STYLES_URL } from "./css";
26
+ import { findGlobalCss, globalCssUsesTailwind, processCssCached, STYLES_URL } from "./css";
27
27
  import { buildModuleCss, registerCssModules, MODULE_STYLES_URL, type ModuleMaps } from "./css-modules";
28
28
 
29
29
  export type CreateAppOptions = {
@@ -107,6 +107,8 @@ export function createApp({ appDir: appDirInput, config = {} }: CreateAppOptions
107
107
  speculationRules: resolveSpeculationRules(speculation ?? undefined),
108
108
  speculationDelivery: speculation ? speculation.delivery ?? "inline" : "inline",
109
109
  viewTransitions: config.viewTransitions ?? true,
110
+ // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
111
+ cssReset: config.cssReset ?? !globalCssUsesTailwind(appDir),
110
112
  clientRouter: config.clientRouter ?? false,
111
113
  clientScript: clientEntry ? CLIENT_SCRIPT_URL : null,
112
114
  styles: cssEntry ? STYLES_URL : null,
package/src/build.ts CHANGED
@@ -35,7 +35,7 @@ import { findMiddlewareFile } from "./router";
35
35
  import { resolveBoundary } from "./segment";
36
36
  import type { ExtraHandler, LayoutComponent, LoadingComponent, ResourceHandler } from "./pipeline";
37
37
  import { findClientEntry, bundleClientToFile, CLIENT_SCRIPT_URL } from "./client-bundle";
38
- import { cssTargets, findGlobalCss, minifyCss, processCss, STYLES_URL } from "./css";
38
+ import { cssTargets, findGlobalCss, globalCssUsesTailwind, minifyCss, processCss, STYLES_URL } from "./css";
39
39
  import { buildModuleCss, rolldownCssModulesPlugin, registerCssModules } from "./css-modules";
40
40
 
41
41
  export type BuildResult = {
@@ -62,6 +62,12 @@ const ROUTE_EXTS = [".tsx", ".jsx", ".ts", ".js"];
62
62
 
63
63
  const isRouteGroup = (name: string) => /^\(.+\)$/.test(name);
64
64
 
65
+ // Bun built-ins (`bun`, `bun:sqlite`, …) exist only at the Bun runtime and must never enter the
66
+ // workerd graph. Marking them external keeps rolldown from constant-folding the `const x = "bun";
67
+ // import(x)` runtime guard (in @junejs/core's cache.ts) and warning UNRESOLVED_IMPORT. Exported so
68
+ // the build keeps externalizing them — see test/build-externals.test.ts.
69
+ export const isBunSpecifier = (id: string): boolean => id === "bun" || id.startsWith("bun:");
70
+
65
71
  function segmentFile(dir: string, base: string): string | undefined {
66
72
  return ROUTE_EXTS.map((e) => join(dir, `${base}${e}`)).find(existsSync);
67
73
  }
@@ -142,6 +148,8 @@ export async function freezeConfig(appRoot: string): Promise<{
142
148
  speculationRules: resolveSpeculationRules(cfg.speculation ?? undefined),
143
149
  speculationDelivery: "inline",
144
150
  viewTransitions: cfg.viewTransitions ?? true,
151
+ // Default the baseline reset OFF when the app uses Tailwind (its Preflight is the reset).
152
+ cssReset: cfg.cssReset ?? !globalCssUsesTailwind(join(appRoot, "app")),
145
153
  clientRouter: cfg.clientRouter ?? false,
146
154
  clientScript: hasClient ? CLIENT_SCRIPT_URL : null,
147
155
  styles: hasCss ? STYLES_URL : null,
@@ -516,6 +524,8 @@ ${adapterEntry.wrap("pipeline")}
516
524
  transform: { define: { "process.env.NODE_ENV": JSON.stringify("production") } },
517
525
  plugins: [rolldownCssModulesPlugin(cssModuleMaps)], // .module.css → scoped class map
518
526
  external: (id: string) => {
527
+ // Bun built-ins exist only at Bun runtime, never in the workerd graph (see isBunSpecifier).
528
+ if (isBunSpecifier(id)) return true;
519
529
  // Binary assets stay external — wrangler's CompiledWasm/Data rules own them.
520
530
  if (/\.(wasm|ttf|otf|woff2?|png|jpe?g|avif|webp)$/.test(id)) return true;
521
531
  // config build.external: packages wrangler must bundle itself (its own
package/src/content.ts CHANGED
@@ -79,7 +79,10 @@ function loadEntry(file: string, slug: string, locale?: string): ContentEntry {
79
79
  }
80
80
 
81
81
  const isMd = (f: string) => f.endsWith(".md") || f.endsWith(".mdx");
82
- const slugOf = (f: string) => f.replace(/\.(md|mdx)$/, "");
82
+ // Strip the extension, then collapse a directory index file to the directory itself, so it owns the
83
+ // folder's URL (Next.js / GitBook / Fumadocs convention): `a/b/index.md` → `a/b`, `a/b/README.md` →
84
+ // `a/b`, and a root `index`/`README` → "". A folder with an index becomes a page (not a buried child).
85
+ const slugOf = (f: string) => f.replace(/\.(md|mdx)$/, "").replace(/(^|\/)(index|README)$/i, "");
83
86
  const byDate = (a: ContentEntry, b: ContentEntry) =>
84
87
  String(b.data.date ?? "").localeCompare(String(a.data.date ?? "")) ||
85
88
  a.slug.localeCompare(b.slug);
@@ -104,25 +107,44 @@ function isLocaleDir(name: string, known?: readonly string[]): boolean {
104
107
  return known ? known.includes(name) : LOCALE_DIR.test(name);
105
108
  }
106
109
 
110
+ // Recursively collect markdown under `root`; slug = the path relative to `root`, POSIX, no extension
111
+ // (`guides/install.md` → `guides/install`) so routes and the sidebar can nest. `skipTopLocales` skips
112
+ // locale buckets at the TOP level only (scanned separately) — so a top-level `ja-JP/` is a locale, but
113
+ // a nested `guides/` (or `guides/ja-JP/`) is plain content.
114
+ function collectMd(
115
+ root: string,
116
+ knownLocales: readonly string[] | undefined,
117
+ skipTopLocales: boolean,
118
+ sub = "",
119
+ ): { file: string; slug: string }[] {
120
+ const out: { file: string; slug: string }[] = [];
121
+ for (const ent of readdirSync(join(root, sub), { withFileTypes: true })) {
122
+ const rel = sub ? `${sub}/${ent.name}` : ent.name;
123
+ if (ent.isDirectory()) {
124
+ if (skipTopLocales && sub === "" && isLocaleDir(ent.name, knownLocales)) continue;
125
+ out.push(...collectMd(root, knownLocales, skipTopLocales, rel));
126
+ } else if (isMd(ent.name)) {
127
+ out.push({ file: join(root, rel), slug: slugOf(rel) });
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
107
133
  export function scanCollection(dir: string, knownLocales?: readonly string[]): ScannedCollection {
108
- const def: ContentEntry[] = [];
134
+ // Default-locale entries: every .md under `dir` except inside top-level locale buckets. Flat files
135
+ // keep flat slugs (zero migration); subdirs become slug paths.
136
+ const def = collectMd(dir, knownLocales, true)
137
+ .map(({ file, slug }) => loadEntry(file, slug))
138
+ .sort(byDate);
109
139
  const byLocale: Record<string, ContentEntry[]> = {};
110
140
  for (const ent of readdirSync(dir, { withFileTypes: true })) {
111
- if (ent.isDirectory()) {
112
- // Only a configured locale (or, without a list, a BCP-47-shaped name) is a
113
- // locale bucket — a stray content/<col>/images/ is NOT a phantom "images".
114
- if (!isLocaleDir(ent.name, knownLocales)) continue;
141
+ if (ent.isDirectory() && isLocaleDir(ent.name, knownLocales)) {
115
142
  const locale = ent.name;
116
- const sub = join(dir, locale);
117
- byLocale[locale] = readdirSync(sub)
118
- .filter(isMd)
119
- .map((f) => loadEntry(join(sub, f), slugOf(f), locale))
143
+ byLocale[locale] = collectMd(join(dir, locale), knownLocales, false)
144
+ .map(({ file, slug }) => loadEntry(file, slug, locale))
120
145
  .sort(byDate);
121
- } else if (isMd(ent.name)) {
122
- def.push(loadEntry(join(dir, ent.name), slugOf(ent.name)));
123
146
  }
124
147
  }
125
- def.sort(byDate);
126
148
  return { default: def, byLocale };
127
149
  }
128
150
 
@@ -158,8 +180,9 @@ export function entry(
158
180
  locale?: string,
159
181
  opts?: { fallback?: boolean },
160
182
  ): ContentEntry | null {
161
- // Guard the slug — it comes from the URL.
162
- if (!/^[A-Za-z0-9._-]+$/.test(slug)) return null;
183
+ // Guard the slug — it comes from the URL. Allow "/" so nested content (`guides/install`) resolves,
184
+ // but reject path traversal and absolute/empty segments so the slug can't escape `dir`.
185
+ if (!/^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(slug) || slug.includes("..")) return null;
163
186
  if (!locale) return probe(dir, slug);
164
187
  const variant = probe(join(dir, locale), slug, locale);
165
188
  if (variant) return variant;
@@ -207,6 +230,9 @@ export function generateContentModule(
207
230
  body += `export const ${CONST}: ContentEntry[] = ${JSON.stringify(def.map(strip), null, 2)};\n`;
208
231
  if (Object.keys(byLocale).length === 0) {
209
232
  body += `export const ${finder} = (slug: string): ContentEntry | null => ${CONST}.find((p) => p.slug === slug) ?? null;\n`;
233
+ // Always export the locale lister (a no-op for single-locale collections) so consumers can
234
+ // import `${d.name}` unconditionally — adding/removing locales never changes the module's shape.
235
+ body += `export const ${d.name} = (_locale?: string): ContentEntry[] => ${CONST};\n`;
210
236
  } else {
211
237
  anyLocale = true;
212
238
  const map: Record<string, Record<string, unknown>> = {};
package/src/css.ts CHANGED
@@ -4,15 +4,16 @@
4
4
  // (served immutable). Only the asset HREF diverges dev↔built, never the rendered
5
5
  // structure — so dev/built parity (the agent surface) holds.
6
6
  //
7
- // Floor: plain CSS, zero deps. Blessed: if global.css opts into Tailwind
8
- // (`@import "tailwindcss"`), June compiles it via the app's own Tailwind v4
9
- // (resolved from the app, not bundled into June) — Tailwind auto-detects the
10
- // content it scans. CSS never touches the agent projections (.md/.json/mcp).
7
+ // Floor: plain CSS, zero deps — Lightning CSS (the SAME Rust engine Tailwind v4 uses) handles
8
+ // nesting/prefix/minify. Blessed: if global.css opts into Tailwind (`@import "tailwindcss"`), June
9
+ // compiles it via the app's own Tailwind v4 ENGINE DIRECTLY — @tailwindcss/node + the @tailwindcss/oxide
10
+ // Rust scanner (the first-party Vite-plugin path, not PostCSS) — resolved from the app, napi-portable
11
+ // across Bun and Node. CSS never touches the agent projections (.md/.json/mcp).
11
12
 
12
- import { existsSync } from "node:fs";
13
+ import { existsSync, readFileSync } from "node:fs";
13
14
  import { readFile } from "node:fs/promises";
14
15
  import { createRequire } from "node:module";
15
- import { join } from "node:path";
16
+ import { dirname, join } from "node:path";
16
17
  import { pathToFileURL } from "node:url";
17
18
 
18
19
  import type { Targets } from "lightningcss"; // type-only — never enters the graph
@@ -31,6 +32,18 @@ export function findGlobalCss(appDir: string): string | null {
31
32
 
32
33
  const usesTailwind = (css: string) => /@import\s+["']tailwindcss["']|@tailwind\b/.test(css);
33
34
 
35
+ // True when app/global.css opts into Tailwind. Sync (build-time, tiny file) so the document config —
36
+ // built synchronously in dev (app.ts) and the build (build.ts) — can default `cssReset` off when
37
+ // Tailwind is present: its Preflight IS the reset, so June need not add its own baseline reset.
38
+ export function globalCssUsesTailwind(appDir: string): boolean {
39
+ const p = join(appDir, GLOBAL_CSS);
40
+ try {
41
+ return existsSync(p) && usesTailwind(readFileSync(p, "utf8"));
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
34
47
  // Resolve a tool from the APP's node_modules (Tailwind/postcss are the app's
35
48
  // deps, not June's). Returns null when absent.
36
49
  function resolveFromApp(specifier: string, appDir: string): string | null {
@@ -67,23 +80,40 @@ export async function cssTargets(appDir: string): Promise<Targets> {
67
80
  return targets;
68
81
  }
69
82
 
83
+ // Minimal type surface of the bits of Tailwind v4's engine we drive directly.
84
+ type TwSource = { base: string; pattern: string; negated: boolean };
85
+ type TwCompiler = { build(candidates: string[]): string; sources: TwSource[]; features: number };
86
+ type TwNode = {
87
+ compile(css: string, opts: { base: string; onDependency: (p: string) => void }): Promise<TwCompiler>;
88
+ optimize(css: string, opts: { minify: boolean }): { code: string };
89
+ Features: { Utilities: number };
90
+ };
91
+ type TwOxide = { Scanner: new (o: { sources: TwSource[] }) => { scan(): string[] } };
92
+
93
+ // Compile Tailwind v4 via its programmatic engine — @tailwindcss/node (compile + Lightning CSS optimize)
94
+ // + @tailwindcss/oxide (the Rust content Scanner) — the SAME direct path the first-party Vite plugin
95
+ // uses, NOT PostCSS. No postcss dependency or extra parse pass; the Oxide scanner (Rust) finds class
96
+ // candidates and the compiler emits only those (tree-shaken). Engine resolved from the APP (its dep,
97
+ // not June's); napi works identically on Bun and Node. Returns null when Tailwind isn't installed.
70
98
  async function compileTailwind(
71
99
  css: string,
72
100
  file: string,
73
101
  appDir: string,
74
102
  minify: boolean,
75
103
  ): Promise<string | null> {
76
- const postcssPath = resolveFromApp("postcss", appDir);
77
- const tailwindPath = resolveFromApp("@tailwindcss/postcss", appDir);
78
- if (!postcssPath || !tailwindPath) return null;
79
- const postcss = ((await import(postcssPath)) as { default: (p: unknown[]) => { process(css: string, opts: { from: string }): Promise<{ css: string }> } }).default;
80
- const tailwind = ((await import(tailwindPath)) as { default: (opts?: unknown) => unknown }).default;
81
- // Tailwind v4 only emits the classes the project actually uses (content
82
- // detection = tree-shaking). For build we ALSO minify via its Lightning CSS
83
- // optimizer; dev stays unminified for readability.
84
- const plugin = minify ? tailwind({ optimize: { minify: true } }) : tailwind();
85
- const result = await postcss([plugin]).process(css, { from: file });
86
- return result.css;
104
+ const nodePath = resolveFromApp("@tailwindcss/node", appDir);
105
+ const oxidePath = resolveFromApp("@tailwindcss/oxide", appDir);
106
+ if (!nodePath || !oxidePath) return null;
107
+ const { compile, optimize, Features } = (await import(nodePath)) as TwNode;
108
+ const { Scanner } = (await import(oxidePath)) as TwOxide;
109
+ const base = dirname(file); // the CSS's dir — resolves @import / @source / auto-detection
110
+ const compiler = await compile(css, { base, onDependency: () => {} });
111
+ // Scan the project (app root) plus whatever the compiler declares; Oxide skips node_modules/.gitignore.
112
+ const sources: TwSource[] = [{ base: dirname(appDir), pattern: "**/*", negated: false }, ...compiler.sources];
113
+ const candidates = compiler.features & Features.Utilities ? new Scanner({ sources }).scan() : [];
114
+ const out = compiler.build(candidates);
115
+ // Lightning CSS optimize for build (minify + prefix/lower); dev stays readable.
116
+ return minify ? optimize(out, { minify: true }).code : out;
87
117
  }
88
118
 
89
119
  // Dev-only memo. The dev server re-fetches /global.css on EVERY navigation, but
@@ -138,7 +168,7 @@ export async function processCss(
138
168
  });
139
169
  if (compiled !== null) return compiled; // Tailwind already minified when asked
140
170
  console.warn(
141
- "[june] global.css uses Tailwind but @tailwindcss/postcss isn't installed — serving raw CSS.",
171
+ "[june] global.css uses Tailwind but @tailwindcss/node + @tailwindcss/oxide aren't installed — serving raw CSS.",
142
172
  );
143
173
  }
144
174
  // Plain CSS (or the Tailwind fallback): minify + autoprefix/lower for the app's
package/src/dev-reload.ts CHANGED
@@ -38,10 +38,13 @@ const RELOAD_JS = `// june dev live-reload: reconnect-after-drop → reload; "cs
38
38
  es.addEventListener("open", () => {
39
39
  if (dropped) {
40
40
  // push-HMR: morph the freshly-restarted page in place (state survives);
41
- // hard-reload only if there's no morph hook or it can't apply.
41
+ // hard-reload only if there's no morph hook or it can't apply. On a
42
+ // successful morph also swap the stylesheet: a .tsx edit can change the
43
+ // class set, so the Tailwind-compiled /_june/global.css must refresh too
44
+ // (cache-busted) — otherwise new utility classes render unstyled.
42
45
  const hot = window.__juneLiveReload;
43
46
  (hot ? hot() : Promise.resolve(false)).then(
44
- (ok) => { if (!ok) location.reload(); },
47
+ (ok) => { if (ok) swapCss(); else location.reload(); },
45
48
  () => location.reload(),
46
49
  );
47
50
  }