@teamscala/service-boot 0.23.412 → 0.23.414
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 +113 -1
- package/src/build-client-assets/build-lock.ts +55 -0
- package/src/build-client-assets/hashed-output.ts +39 -0
- package/src/build-client-assets/hydrate-bundle.ts +298 -0
- package/src/build-client-assets/input-fingerprint.ts +108 -0
- package/src/build-client-assets/manifest.ts +115 -0
- package/src/build-client-assets/sourcemaps-upload.ts +101 -0
- package/src/build-client-assets/styles-build.ts +179 -0
- package/src/build-client-assets.test.ts +2 -4
- package/src/build-client-assets.ts +20 -777
- package/src/read-html-template.ts +1 -1
- package/src/register-registry-page-route/apply-page-branding.ts +207 -0
- package/src/register-registry-page-route/meta-title.ts +15 -0
- package/src/register-registry-page-route/prefetch-org-id.ts +38 -0
- package/src/register-registry-page-route/resolve-panel-page.ts +45 -0
- package/src/register-registry-page-route/resolve-request-graph.ts +170 -0
- package/src/register-registry-page-route/run-content-gates.ts +80 -0
- package/src/register-registry-page-route/stamp-session-context.ts +121 -0
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +64 -0
- package/src/register-registry-page-route.ts +64 -599
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-handlers
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The asset-manifest unit of the client-asset build: the AssetManifest shape,
|
|
7
|
+
* its read/write, the last-failure tombstone status read the runtime surfaces,
|
|
8
|
+
* and the first-boot fallback paths.
|
|
9
|
+
*
|
|
10
|
+
* Split out of build-client-assets.ts (962 lines) alongside its build
|
|
11
|
+
* siblings — the file held six concerns (manifest, hashed output, hydrate
|
|
12
|
+
* bundle, source-map upload, stylesheet build, fingerprint, lock, orchestration).
|
|
13
|
+
*
|
|
14
|
+
* Byte-for-byte move from the parent — imports adjusted, nothing else.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const MANIFEST_FILE = "asset-manifest.json";
|
|
20
|
+
|
|
21
|
+
export interface AssetManifest {
|
|
22
|
+
stylesPath: string;
|
|
23
|
+
hydratePath: string;
|
|
24
|
+
// Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source
|
|
25
|
+
// entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
|
|
26
|
+
// versions), hashed by CONTENT — never mtime (cache-key-hashes-source-
|
|
27
|
+
// inputs-only). The boot/build freshness check rebuilds iff this differs
|
|
28
|
+
// from the current closure. mtime comparison is fundamentally broken on
|
|
29
|
+
// Railway: deploy-build bakes the bundle at image-build time, and under the
|
|
30
|
+
// hoisted linker + warm `bun-install` cache the installed @teamscala/* files
|
|
31
|
+
// are hardlinks carrying OLD extraction mtimes while the baked manifest's
|
|
32
|
+
// mtime is newer — so `source.mtime > manifest.mtime` is false and the
|
|
33
|
+
// build wrongly skips, baking (then serving forever) a STALE client bundle
|
|
34
|
+
// even though a newer @teamscala/* version is installed. Observed 2026-06-14:
|
|
35
|
+
// scala-gpt shipped ui-foundation < 85 in the bundle while a newer version
|
|
36
|
+
// was installed, so a published+installed fix never reached the browser.
|
|
37
|
+
// Content hashing of the closure is immune to mtime games. bun.lock is
|
|
38
|
+
// excluded by design — see computeInputFingerprint.
|
|
39
|
+
inputFingerprint?: string;
|
|
40
|
+
// TOMBSTONE fields — present iff the last build FAILED (no-silent-reverts).
|
|
41
|
+
// `buildFailed: true` + reason + timestamp are written by performBuild
|
|
42
|
+
// before it re-throws. The next boot's fingerprint check sees the
|
|
43
|
+
// `failed:<iso>` sentinel and cannot match → forces a retry (no silent
|
|
44
|
+
// skip on the stale manifest). read-html-template reads these to stamp
|
|
45
|
+
// the served HTML with the failure status (operator-visible signal that
|
|
46
|
+
// the bundle is stale, not fresh).
|
|
47
|
+
buildFailed?: boolean;
|
|
48
|
+
buildFailedReason?: string;
|
|
49
|
+
buildFailedAt?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Last-resort paths for a tombstone when no prior successful manifest exists
|
|
53
|
+
// (first-ever boot fails). Points at the unhashed build names so an operator
|
|
54
|
+
// inspecting the manifest sees a real (if 404-ing) target rather than a
|
|
55
|
+
// mystery sentinel.
|
|
56
|
+
export const FALLBACK_MANIFEST: { stylesPath: string; hydratePath: string } = {
|
|
57
|
+
stylesPath: "/assets/styles.css",
|
|
58
|
+
hydratePath: "/assets/hydrate.js",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read ONLY the tombstone state of the current manifest — the runtime
|
|
63
|
+
* (read-html-template, ui-service) calls this to decide whether to surface a
|
|
64
|
+
* build-failure signal. Returns null when the manifest is fresh (no
|
|
65
|
+
* `buildFailed`). Decoupled from the full AssetManifest read so the runtime
|
|
66
|
+
* never depends on the build internals.
|
|
67
|
+
*/
|
|
68
|
+
export async function readClientBuildStatus(
|
|
69
|
+
cwd = process.cwd(),
|
|
70
|
+
): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
|
|
71
|
+
const path = join(cwd, "dist", "client", MANIFEST_FILE);
|
|
72
|
+
if (!(await Bun.file(path).exists())) return { failed: false };
|
|
73
|
+
try {
|
|
74
|
+
const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
|
|
75
|
+
if (parsed.buildFailed === true) {
|
|
76
|
+
return {
|
|
77
|
+
failed: true,
|
|
78
|
+
reason: parsed.buildFailedReason,
|
|
79
|
+
at: parsed.buildFailedAt,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
return { failed: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readManifest(outDir: string): Promise<AssetManifest | null> {
|
|
87
|
+
const path = join(outDir, MANIFEST_FILE);
|
|
88
|
+
if (!(await Bun.file(path).exists())) return null;
|
|
89
|
+
try {
|
|
90
|
+
const parsed = await Bun.file(path).json() as Partial<AssetManifest>;
|
|
91
|
+
if (parsed.stylesPath && parsed.hydratePath) {
|
|
92
|
+
return {
|
|
93
|
+
stylesPath: parsed.stylesPath,
|
|
94
|
+
hydratePath: parsed.hydratePath,
|
|
95
|
+
inputFingerprint: parsed.inputFingerprint,
|
|
96
|
+
buildFailed: parsed.buildFailed,
|
|
97
|
+
buildFailedReason: parsed.buildFailedReason,
|
|
98
|
+
buildFailedAt: parsed.buildFailedAt,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function writeManifest(
|
|
108
|
+
outDir: string,
|
|
109
|
+
manifest: AssetManifest,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
await Bun.write(
|
|
112
|
+
join(outDir, MANIFEST_FILE),
|
|
113
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-handlers
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
|
|
7
|
+
* flow, then delete the .map files so source never ships publicly.
|
|
8
|
+
*
|
|
9
|
+
* Split out of build-client-assets.ts (962 lines) alongside its build
|
|
10
|
+
* siblings. Byte-for-byte move from the parent — imports adjusted, nothing
|
|
11
|
+
* else.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { readdir, rm } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
18
|
+
import { userHome } from "@teamscala/os/host-paths";
|
|
19
|
+
import { spawnSync } from "@teamscala/os/spawn";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Upload source maps to GlitchTip via the official `glitchtip-cli` debug-ID
|
|
23
|
+
* flow (the documented first-class mechanism — `sourcemaps inject` then
|
|
24
|
+
* `sourcemaps upload --release`; see telemetry.md `client-errors-report-to-glitchtip`
|
|
25
|
+
* + https://glitchtip.com/documentation/cli). Debug IDs injected into the served
|
|
26
|
+
* bundles + uploaded maps are the matching mechanism — robust vs URL-prefix
|
|
27
|
+
* guessing. Best-effort + dev-host-only: skips silently when the CLI or
|
|
28
|
+
* SENTRY_AUTH_TOKEN is absent (Railway builds don't install the CLI; prod source
|
|
29
|
+
* maps are a follow-up). The .map files are deleted after upload — source never
|
|
30
|
+
* ships publicly; the debug IDs in the served bundles are all the SDK needs.
|
|
31
|
+
*/
|
|
32
|
+
export async function uploadSourceMaps(
|
|
33
|
+
outDir: string,
|
|
34
|
+
release: string,
|
|
35
|
+
log: ReturnType<typeof getAppLogger>,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const token = process.env.SENTRY_AUTH_TOKEN;
|
|
38
|
+
const sentryUrl = process.env.SENTRY_URL;
|
|
39
|
+
if (!token || !sentryUrl) return; // best-effort: no token/URL → skip
|
|
40
|
+
const org = process.env.SENTRY_ORG ?? "scala";
|
|
41
|
+
const project = process.env.SENTRY_PROJECT ?? "scala-hub";
|
|
42
|
+
const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
|
|
43
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
44
|
+
try {
|
|
45
|
+
// Inject debug IDs into the bundles + maps (if glitchtip-cli is present).
|
|
46
|
+
// The CLI is optional — only used for the inject step.
|
|
47
|
+
if (existsSync(cli)) {
|
|
48
|
+
spawnSync({
|
|
49
|
+
name: "glitchtip-cli-sourcemaps-inject",
|
|
50
|
+
command: [cli, "sourcemaps", "inject", outDir],
|
|
51
|
+
env: { ...process.env, SENTRY_AUTH_TOKEN: token },
|
|
52
|
+
timeoutMs: 60_000,
|
|
53
|
+
captureStderr: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// Upload source maps via the direct release-files API (NOT the CLI's
|
|
57
|
+
// chunk-upload — that hits a host-mismatch behind reverse proxies: the
|
|
58
|
+
// server returns a stale internal URL the CLI refuses to send the token to).
|
|
59
|
+
// The direct API posts to SENTRY_URL (which we control) — no server-returned
|
|
60
|
+
// URL, no host mismatch.
|
|
61
|
+
await fetch(`${sentryUrl}/api/0/organizations/${org}/releases/`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ version: release, projects: [project] }),
|
|
65
|
+
});
|
|
66
|
+
let uploaded = 0;
|
|
67
|
+
for (const name of await readdir(outDir, "utf-8")) {
|
|
68
|
+
if (!name.endsWith(".map")) continue;
|
|
69
|
+
try {
|
|
70
|
+
const mapBytes = await Bun.file(join(outDir, name)).bytes();
|
|
71
|
+
const form = new FormData();
|
|
72
|
+
form.append("name", `~/assets/${name.replace(".map", "")}`);
|
|
73
|
+
form.append("file", new Blob([mapBytes]), name);
|
|
74
|
+
const res = await fetch(
|
|
75
|
+
`${sentryUrl}/api/0/projects/${org}/${project}/releases/${release}/files/`,
|
|
76
|
+
{ method: "POST", headers, body: form },
|
|
77
|
+
);
|
|
78
|
+
if (res.ok) uploaded++;
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
log.info("[build-client-assets] source maps uploaded to GlitchTip", {
|
|
82
|
+
release,
|
|
83
|
+
project,
|
|
84
|
+
uploaded,
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
log.warn("[build-client-assets] source-map upload error (best-effort, skipped)", {
|
|
88
|
+
release,
|
|
89
|
+
error: err instanceof Error ? err.message : String(err),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Delete the .map files — never serve source. The debug IDs injected into the
|
|
93
|
+
// served bundles are all the SDK needs; the maps live in GlitchTip keyed by them.
|
|
94
|
+
for (const name of await readdir(outDir, "utf-8")) {
|
|
95
|
+
if (name.endsWith(".map")) {
|
|
96
|
+
try {
|
|
97
|
+
await rm(join(outDir, name));
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-handlers
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The Tailwind stylesheet unit of the client-asset build: Bun-native
|
|
7
|
+
* stylesheet resolution, recursive inlining of nested @imports (the
|
|
8
|
+
* 2026-07-05 dropped-leaf outage), stripping of remote (CSP-forbidden)
|
|
9
|
+
* @imports, and the content-hashed styles-<hash>.css build.
|
|
10
|
+
*
|
|
11
|
+
* Split out of build-client-assets.ts (962 lines) alongside its build
|
|
12
|
+
* siblings. Byte-for-byte move from the parent — imports adjusted, nothing
|
|
13
|
+
* else.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
|
+
import { compile } from "tailwindcss";
|
|
20
|
+
import { fileHash, removeStaleHashedFiles } from "./hashed-output.ts";
|
|
21
|
+
import { scanSourceCandidates } from "./source-candidates.ts";
|
|
22
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
23
|
+
|
|
24
|
+
const STYLES_BASE = "styles";
|
|
25
|
+
|
|
26
|
+
async function resolveStylesheetRaw(
|
|
27
|
+
id: string,
|
|
28
|
+
base: string,
|
|
29
|
+
cwd: string,
|
|
30
|
+
): Promise<{ path: string; base: string; content: string }> {
|
|
31
|
+
if (id === "tailwindcss") {
|
|
32
|
+
const r = await Bun.resolve("tailwindcss/index.css", cwd);
|
|
33
|
+
return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const r = await Bun.resolve(id, base);
|
|
37
|
+
return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
|
|
38
|
+
} catch {}
|
|
39
|
+
if (!id.startsWith(".")) {
|
|
40
|
+
try {
|
|
41
|
+
const pkgPath = await Bun.resolve(id + "/package.json", cwd);
|
|
42
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
|
|
43
|
+
const entry =
|
|
44
|
+
pkg.exports?.["."]?.style || pkg.style || pkg.main;
|
|
45
|
+
if (entry) {
|
|
46
|
+
const r = resolve(dirname(pkgPath), entry);
|
|
47
|
+
return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
const r = resolve(base, id);
|
|
52
|
+
return { path: r, base: dirname(r), content: await readFile(r, "utf-8") };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the
|
|
56
|
+
// entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
|
|
57
|
+
// content — so a package styles.css that itself `@import "./Leaf.css"` (a
|
|
58
|
+
// nested RELATIVE import) has its leaf silently dropped from the prod bundle.
|
|
59
|
+
// This dropped every block-specific class reachable only via a nested
|
|
60
|
+
// relative @import (project-list-view/ProjectListView.css's
|
|
61
|
+
// .nested-list-table-host, audit-block's nested css, etc.) while direct
|
|
62
|
+
// package @imports (generic-ui/blk-utilities → .blk-*) survived — the
|
|
63
|
+
// 2026-07-05 #6 outage (broken projects UI). Inline nested @imports here so
|
|
64
|
+
// the content returned to Tailwind is flat; the compile() loadStylesheet
|
|
65
|
+
// callback then never sees a nested @import it would drop. Cycle-safe via
|
|
66
|
+
// `seen` (mirrors hashResolvedPackageStylesheet's transitive walk).
|
|
67
|
+
const CSS_IMPORT_RE = /@import\s+(?:url\()?\s*["']([^"']+)["']\s*\)?[^;]*;?/g;
|
|
68
|
+
async function inlineCssImports(
|
|
69
|
+
css: string,
|
|
70
|
+
base: string,
|
|
71
|
+
cwd: string,
|
|
72
|
+
seen: Set<string>,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
let result = "";
|
|
75
|
+
let lastIdx = 0;
|
|
76
|
+
for (const m of css.matchAll(CSS_IMPORT_RE)) {
|
|
77
|
+
const spec = m[1].trim();
|
|
78
|
+
result += css.slice(lastIdx, m.index);
|
|
79
|
+
lastIdx = (m.index ?? 0) + m[0].length;
|
|
80
|
+
// Keep the tailwindcss entry verbatim (Tailwind compile owns it —
|
|
81
|
+
// including any trailing `source(none)` etc. — via m[0]).
|
|
82
|
+
if (spec === "tailwindcss" || spec.startsWith("tailwindcss/")) {
|
|
83
|
+
result += m[0];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (seen.has(spec)) continue; // cycle guard
|
|
87
|
+
seen.add(spec);
|
|
88
|
+
try {
|
|
89
|
+
const resolved = await resolveStylesheetRaw(spec, base, cwd);
|
|
90
|
+
result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
|
|
91
|
+
} catch {
|
|
92
|
+
result += m[0]; // unresolved — keep literal; CSS/Tailwind will surface it
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
result += css.slice(lastIdx);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function bunResolveStylesheet(
|
|
100
|
+
id: string,
|
|
101
|
+
base: string,
|
|
102
|
+
cwd: string,
|
|
103
|
+
): Promise<{ path: string; base: string; content: string }> {
|
|
104
|
+
const resolved = await resolveStylesheetRaw(id, base, cwd);
|
|
105
|
+
if (id === "tailwindcss" || id.startsWith("tailwindcss/")) return resolved;
|
|
106
|
+
const content = await inlineCssImports(resolved.content, resolved.base, cwd, new Set([resolved.path]));
|
|
107
|
+
return { ...resolved, content };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html
|
|
111
|
+
// (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
|
|
112
|
+
// fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote
|
|
113
|
+
// `@import url(http…)`: @puckeditor/core/dist/index.css opens with
|
|
114
|
+
// `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does
|
|
115
|
+
// NOT allow. The Tailwind compile inlines that mid-output, where it was either
|
|
116
|
+
// silently dropped (CSS spec: @import must precede all rules) OR — once hoisted —
|
|
117
|
+
// blocked by CSP, leaving a console error on every page; either way the font
|
|
118
|
+
// never loaded and the site fell back to a system font (scala-docs 2026-06-13).
|
|
119
|
+
// So every remote @import is STRIPPED from the compiled bundle: a package can
|
|
120
|
+
// never inject a CDN font/stylesheet, and the only font source is the app-owned
|
|
121
|
+
// index.html link. Fixed once at the single CSS chokepoint; guarded by
|
|
122
|
+
// `css-build-strips-remote-imports`.
|
|
123
|
+
const REMOTE_IMPORT_RE =
|
|
124
|
+
/^[ \t]*@import\s+(?:url\(\s*)?["']https?:\/\/[^"')]+["']\s*\)?[^;]*;[ \t]*\n?/gm;
|
|
125
|
+
|
|
126
|
+
export function stripRemoteImports(css: string): string {
|
|
127
|
+
return css.replace(REMOTE_IMPORT_RE, "");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
export async function buildTailwindCss(
|
|
132
|
+
cwd: string,
|
|
133
|
+
log: ReturnType<typeof getAppLogger>,
|
|
134
|
+
): Promise<string | null> {
|
|
135
|
+
const entry = join(cwd, "src", "app.css");
|
|
136
|
+
const outDir = join(cwd, "dist", "client");
|
|
137
|
+
const tmpFile = join(outDir, `${STYLES_BASE}.css`);
|
|
138
|
+
|
|
139
|
+
if (!(await Bun.file(entry).exists())) {
|
|
140
|
+
log.warn(
|
|
141
|
+
`[build-client-assets] src/app.css not found at ${entry} — skipping CSS build`,
|
|
142
|
+
);
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await mkdir(outDir, { recursive: true });
|
|
147
|
+
|
|
148
|
+
const cssSource = await readFile(entry, "utf-8");
|
|
149
|
+
const compiler = await compile(cssSource, {
|
|
150
|
+
base: cwd,
|
|
151
|
+
async loadStylesheet(id, base) {
|
|
152
|
+
return bunResolveStylesheet(id, base, cwd);
|
|
153
|
+
},
|
|
154
|
+
async loadModule(id, base) {
|
|
155
|
+
const r = await Bun.resolve(id, base);
|
|
156
|
+
const mod = await import(r);
|
|
157
|
+
return { path: r, base: dirname(r), module: mod.default ?? mod };
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Scan every resolved @source for candidate class names — WITHOUT this the
|
|
162
|
+
// build emits no content-driven utilities (the unstyled-editor bug above).
|
|
163
|
+
const candidates = await scanSourceCandidates(compiler.sources, log);
|
|
164
|
+
const cssOutput = stripRemoteImports(compiler.build(candidates));
|
|
165
|
+
log.info(
|
|
166
|
+
`[build-client-assets] tailwind: ${candidates.length} candidate(s) from ${compiler.sources.length} source(s)`,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
await Bun.write(tmpFile, cssOutput);
|
|
170
|
+
|
|
171
|
+
const hash = await fileHash(tmpFile);
|
|
172
|
+
const hashedName = `${STYLES_BASE}-${hash}.css`;
|
|
173
|
+
const hashedPath = join(outDir, hashedName);
|
|
174
|
+
await rename(tmpFile, hashedPath);
|
|
175
|
+
removeStaleHashedFiles(outDir, STYLES_BASE, "css", hashedName);
|
|
176
|
+
|
|
177
|
+
log.info(`[build-client-assets] styles.css built → ${hashedName} (in-process Bun compile)`);
|
|
178
|
+
return `/assets/${hashedName}`;
|
|
179
|
+
}
|
|
@@ -15,10 +15,8 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
stripRemoteImports,
|
|
21
|
-
} from "./build-client-assets.ts";
|
|
18
|
+
import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
|
|
19
|
+
import { stripRemoteImports } from "./build-client-assets/styles-build.ts";
|
|
22
20
|
|
|
23
21
|
describe("stripRemoteImports", () => {
|
|
24
22
|
test("removes a remote @import (Puck's rsms.me font)", () => {
|