@tailwind-styled/compiler 2.0.0
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 +37 -0
- package/src/astParser.ts +397 -0
- package/src/astTransform.ts +359 -0
- package/src/atomicCss.ts +275 -0
- package/src/classExtractor.ts +69 -0
- package/src/classMerger.ts +45 -0
- package/src/componentGenerator.ts +91 -0
- package/src/componentHoister.ts +183 -0
- package/src/deadStyleEliminator.ts +398 -0
- package/src/incrementalEngine.ts +786 -0
- package/src/index.ts +100 -0
- package/src/loadTailwindConfig.ts +144 -0
- package/src/routeCssCollector.ts +188 -0
- package/src/rscAnalyzer.ts +240 -0
- package/src/safelistGenerator.ts +116 -0
- package/src/staticVariantCompiler.ts +240 -0
- package/src/styleBucketSystem.ts +498 -0
- package/src/styleRegistry.ts +462 -0
- package/src/tailwindEngine.ts +285 -0
- package/src/twDetector.ts +54 -0
- package/src/variantCompiler.ts +87 -0
- package/tsconfig.json +9 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// ── Core transform pipeline ──────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type { TransformOptions, TransformResult } from "./astTransform"
|
|
4
|
+
export { shouldProcess, transformSource } from "./astTransform"
|
|
5
|
+
export type { AtomicRule } from "./atomicCss"
|
|
6
|
+
// ── Atomic CSS ────────────────────────────────────────────────────────────────
|
|
7
|
+
export {
|
|
8
|
+
clearAtomicRegistry,
|
|
9
|
+
generateAtomicCss,
|
|
10
|
+
getAtomicRegistry,
|
|
11
|
+
parseAtomicClass,
|
|
12
|
+
toAtomicClasses,
|
|
13
|
+
} from "./atomicCss"
|
|
14
|
+
// ── Class utilities ───────────────────────────────────────────────────────────
|
|
15
|
+
export { extractAllClasses } from "./classExtractor"
|
|
16
|
+
export { mergeClassesStatic, normalizeClasses } from "./classMerger"
|
|
17
|
+
export type { HoistResult } from "./componentHoister"
|
|
18
|
+
// ── Component hoisting ────────────────────────────────────────────────────────
|
|
19
|
+
export { hoistComponents } from "./componentHoister"
|
|
20
|
+
export type {
|
|
21
|
+
EliminationReport,
|
|
22
|
+
VariantUsage,
|
|
23
|
+
} from "./deadStyleEliminator"
|
|
24
|
+
// ── Dead Style Eliminator ─────────────────────────────────────────────────────
|
|
25
|
+
export {
|
|
26
|
+
extractComponentUsage,
|
|
27
|
+
runElimination,
|
|
28
|
+
scanProjectUsage,
|
|
29
|
+
} from "./deadStyleEliminator"
|
|
30
|
+
export type {
|
|
31
|
+
CssDiff,
|
|
32
|
+
FileDependencyGraph,
|
|
33
|
+
IncrementalEngineOptions,
|
|
34
|
+
IncrementalStats,
|
|
35
|
+
ProcessResult,
|
|
36
|
+
StyleNode,
|
|
37
|
+
} from "./incrementalEngine"
|
|
38
|
+
// ── Incremental CSS Compiler ──────────────────────────────────────────────────
|
|
39
|
+
export {
|
|
40
|
+
getIncrementalEngine,
|
|
41
|
+
IncrementalEngine,
|
|
42
|
+
parseClassesToNodes,
|
|
43
|
+
resetIncrementalEngine,
|
|
44
|
+
} from "./incrementalEngine"
|
|
45
|
+
// ── Zero-config Tailwind config loader ───────────────────────────────────────
|
|
46
|
+
export {
|
|
47
|
+
bootstrapZeroConfig,
|
|
48
|
+
getContentPaths,
|
|
49
|
+
invalidateConfigCache,
|
|
50
|
+
isZeroConfig,
|
|
51
|
+
loadTailwindConfig,
|
|
52
|
+
} from "./loadTailwindConfig"
|
|
53
|
+
export type { RouteClassMap } from "./routeCssCollector"
|
|
54
|
+
// ── Route CSS collector ────────────────────────────────────────────────────────
|
|
55
|
+
export {
|
|
56
|
+
fileToRoute,
|
|
57
|
+
getAllRoutes,
|
|
58
|
+
getCollector,
|
|
59
|
+
getCollectorSummary,
|
|
60
|
+
getRouteClasses,
|
|
61
|
+
registerFileClasses,
|
|
62
|
+
registerGlobalClasses,
|
|
63
|
+
resetCollector,
|
|
64
|
+
} from "./routeCssCollector"
|
|
65
|
+
export type { ComponentEnv, RscAnalysis, StaticVariantUsage } from "./rscAnalyzer"
|
|
66
|
+
// ── RSC-Aware ─────────────────────────────────────────────────────────────────
|
|
67
|
+
export {
|
|
68
|
+
analyzeFile,
|
|
69
|
+
analyzeVariantUsage,
|
|
70
|
+
injectClientDirective,
|
|
71
|
+
injectServerOnlyComment,
|
|
72
|
+
resolveServerVariant,
|
|
73
|
+
} from "./rscAnalyzer"
|
|
74
|
+
// ── Safelist ──────────────────────────────────────────────────────────────────
|
|
75
|
+
export { generateSafelist, generateSafelistCss, loadSafelist } from "./safelistGenerator"
|
|
76
|
+
export type {
|
|
77
|
+
BucketStats,
|
|
78
|
+
ConflictWarning,
|
|
79
|
+
StyleBucket,
|
|
80
|
+
} from "./styleBucketSystem"
|
|
81
|
+
// ── Style Bucket System ───────────────────────────────────────────────────────
|
|
82
|
+
export {
|
|
83
|
+
BucketEngine,
|
|
84
|
+
bucketSort,
|
|
85
|
+
classifyNode,
|
|
86
|
+
detectConflicts,
|
|
87
|
+
getBucketEngine,
|
|
88
|
+
resetBucketEngine,
|
|
89
|
+
} from "./styleBucketSystem"
|
|
90
|
+
export type { CssGenerateResult, TailwindEngineOptions } from "./tailwindEngine"
|
|
91
|
+
// ── Embedded Tailwind engine ──────────────────────────────────────────────────
|
|
92
|
+
export { generateAllRouteCss, generateCssForClasses } from "./tailwindEngine"
|
|
93
|
+
// ── Detectors ─────────────────────────────────────────────────────────────────
|
|
94
|
+
export {
|
|
95
|
+
hasInteractiveFeatures,
|
|
96
|
+
hasTwUsage,
|
|
97
|
+
isDynamic,
|
|
98
|
+
isServerComponent,
|
|
99
|
+
} from "./twDetector"
|
|
100
|
+
export { compileVariants } from "./variantCompiler"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — Tailwind Config Loader
|
|
3
|
+
*
|
|
4
|
+
* Auto-load tailwind config dari project.
|
|
5
|
+
* Jika tidak ada → fallback ke defaultPreset (zero-config mode).
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. tailwind.config.ts (TypeScript)
|
|
9
|
+
* 2. tailwind.config.js (JavaScript)
|
|
10
|
+
* 3. tailwind.config.mjs (ESM)
|
|
11
|
+
* 4. defaultPreset (fallback — zero-config)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
|
|
17
|
+
export type TailwindConfig = Record<string, any>
|
|
18
|
+
|
|
19
|
+
const CONFIG_FILES = [
|
|
20
|
+
"tailwind.config.ts",
|
|
21
|
+
"tailwind.config.js",
|
|
22
|
+
"tailwind.config.mjs",
|
|
23
|
+
"tailwind.config.cjs",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
let _cachedConfig: TailwindConfig | null = null
|
|
27
|
+
let _cachedCwd: string = ""
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load tailwind config. Cached per process.
|
|
31
|
+
* Returns defaultPreset if no config found (zero-config mode).
|
|
32
|
+
*/
|
|
33
|
+
export function loadTailwindConfig(cwd = process.cwd()): TailwindConfig {
|
|
34
|
+
// Cache invalidation
|
|
35
|
+
if (_cachedConfig && _cachedCwd === cwd) return _cachedConfig
|
|
36
|
+
|
|
37
|
+
_cachedCwd = cwd
|
|
38
|
+
|
|
39
|
+
// Try each config file
|
|
40
|
+
for (const file of CONFIG_FILES) {
|
|
41
|
+
const fullPath = path.join(cwd, file)
|
|
42
|
+
if (fs.existsSync(fullPath)) {
|
|
43
|
+
try {
|
|
44
|
+
// For .ts files, we need ts-node or pre-compiled version
|
|
45
|
+
// In practice, Next.js/Vite already handle this via their config system
|
|
46
|
+
const mod = require(fullPath)
|
|
47
|
+
const config = mod.default ?? mod
|
|
48
|
+
_cachedConfig = config
|
|
49
|
+
console.log(`[tailwind-styled-v4] Using config: ${file}`)
|
|
50
|
+
return config
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Zero-config fallback
|
|
56
|
+
console.log("[tailwind-styled-v4] No tailwind config found → using built-in preset")
|
|
57
|
+
const { defaultPreset } = require("../../preset/src/defaultPreset")
|
|
58
|
+
_cachedConfig = defaultPreset
|
|
59
|
+
return defaultPreset
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get content paths dari config (atau default paths)
|
|
64
|
+
*/
|
|
65
|
+
export function getContentPaths(config: TailwindConfig, cwd = process.cwd()): string[] {
|
|
66
|
+
const paths: string[] = []
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(config.content)) {
|
|
69
|
+
for (const item of config.content) {
|
|
70
|
+
if (typeof item === "string") paths.push(item)
|
|
71
|
+
else if (typeof item === "object" && item.raw) {
|
|
72
|
+
// inline content object — skip
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return paths
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (config.content?.files) {
|
|
79
|
+
return config.content.files.filter((f: any) => typeof f === "string")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fallback: scan standard dirs
|
|
83
|
+
return ["src", "app", "pages", "components"]
|
|
84
|
+
.filter((d) => fs.existsSync(path.join(cwd, d)))
|
|
85
|
+
.map((d) => `./${d}/**/*.{tsx,ts,jsx,js}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Invalidate config cache (useful for watch mode)
|
|
90
|
+
*/
|
|
91
|
+
export function invalidateConfigCache(): void {
|
|
92
|
+
_cachedConfig = null
|
|
93
|
+
_cachedCwd = ""
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if project has zero-config setup (no user tailwind config)
|
|
98
|
+
*/
|
|
99
|
+
export function isZeroConfig(cwd = process.cwd()): boolean {
|
|
100
|
+
return !CONFIG_FILES.some((f) => fs.existsSync(path.join(cwd, f)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Auto-generate tailwind.config.ts dan globals.css jika tidak ada
|
|
105
|
+
* (dipanggil oleh CLI dan withTailwindStyled pada first run)
|
|
106
|
+
*/
|
|
107
|
+
export function bootstrapZeroConfig(cwd = process.cwd()): {
|
|
108
|
+
generatedConfig: boolean
|
|
109
|
+
generatedCss: boolean
|
|
110
|
+
} {
|
|
111
|
+
let generatedConfig = false
|
|
112
|
+
let generatedCss = false
|
|
113
|
+
|
|
114
|
+
// Tailwind v4: CSS-first — tidak perlu tailwind.config.ts
|
|
115
|
+
// Config dilakukan via CSS (@source, @theme, dsb.)
|
|
116
|
+
generatedConfig = false
|
|
117
|
+
|
|
118
|
+
// Generate globals.css if missing
|
|
119
|
+
const cssPaths = [
|
|
120
|
+
"src/app/globals.css",
|
|
121
|
+
"app/globals.css",
|
|
122
|
+
"src/index.css",
|
|
123
|
+
"src/styles/globals.css",
|
|
124
|
+
]
|
|
125
|
+
const hasGlobalCss = cssPaths.some((p) => fs.existsSync(path.join(cwd, p)))
|
|
126
|
+
|
|
127
|
+
if (!hasGlobalCss) {
|
|
128
|
+
const { defaultGlobalCss } = require("../../preset/src/defaultPreset")
|
|
129
|
+
// Try to find app directory
|
|
130
|
+
const appDir = fs.existsSync(path.join(cwd, "src/app"))
|
|
131
|
+
? "src/app"
|
|
132
|
+
: fs.existsSync(path.join(cwd, "app"))
|
|
133
|
+
? "app"
|
|
134
|
+
: "src"
|
|
135
|
+
const cssPath = path.join(cwd, appDir, "globals.css")
|
|
136
|
+
if (fs.existsSync(path.dirname(cssPath))) {
|
|
137
|
+
fs.writeFileSync(cssPath, defaultGlobalCss)
|
|
138
|
+
generatedCss = true
|
|
139
|
+
console.log(`[tailwind-styled-v4] Generated ${cssPath}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { generatedConfig, generatedCss }
|
|
144
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — Route CSS Collector
|
|
3
|
+
*
|
|
4
|
+
* Mengumpulkan Tailwind classes per-route sehingga setiap halaman
|
|
5
|
+
* hanya memuat CSS yang benar-benar dipakai.
|
|
6
|
+
*
|
|
7
|
+
* Tailwind default: ~300kb global CSS
|
|
8
|
+
* Route CSS: ~2–10kb per halaman
|
|
9
|
+
*
|
|
10
|
+
* Cara kerja:
|
|
11
|
+
* 1. Setiap file yang di-transform oleh compiler melaporkan classnya
|
|
12
|
+
* 2. Collector memetakan file → route
|
|
13
|
+
* 3. Di akhir build, CSS di-generate per route
|
|
14
|
+
*
|
|
15
|
+
* File structure output:
|
|
16
|
+
* .next/static/css/
|
|
17
|
+
* _global.css ← base + reset (sekali load)
|
|
18
|
+
* app/page.css ← hanya class yang dipakai di /
|
|
19
|
+
* app/about/page.css ← hanya class untuk /about
|
|
20
|
+
* app/dashboard/...
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Types
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface RouteClassMap {
|
|
28
|
+
/** filepath → array of tw classes */
|
|
29
|
+
files: Map<string, Set<string>>
|
|
30
|
+
/** route → Set of files yang dipakai */
|
|
31
|
+
routes: Map<string, Set<string>>
|
|
32
|
+
/** Global classes (di-load semua route) */
|
|
33
|
+
global: Set<string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Singleton collector (per build)
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
let _collector: RouteClassMap = {
|
|
41
|
+
files: new Map(),
|
|
42
|
+
routes: new Map(),
|
|
43
|
+
global: new Set(),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Register classes dari sebuah file setelah compiler transform.
|
|
48
|
+
* Dipanggil oleh turbopackLoader/webpackLoader setelah setiap file di-transform.
|
|
49
|
+
*/
|
|
50
|
+
export function registerFileClasses(filepath: string, classes: string[]): void {
|
|
51
|
+
if (!_collector.files.has(filepath)) {
|
|
52
|
+
_collector.files.set(filepath, new Set())
|
|
53
|
+
}
|
|
54
|
+
const fileSet = _collector.files.get(filepath)!
|
|
55
|
+
classes.forEach((c) => fileSet.add(c))
|
|
56
|
+
|
|
57
|
+
// Auto-detect route dari filepath
|
|
58
|
+
const route = fileToRoute(filepath)
|
|
59
|
+
if (route) {
|
|
60
|
+
if (!_collector.routes.has(route)) {
|
|
61
|
+
_collector.routes.set(route, new Set())
|
|
62
|
+
}
|
|
63
|
+
_collector.routes.get(route)!.add(filepath)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Register global classes (base styles, layout, dsb.)
|
|
69
|
+
* Global classes dimuat di semua route.
|
|
70
|
+
*/
|
|
71
|
+
export function registerGlobalClasses(classes: string[]): void {
|
|
72
|
+
classes.forEach((c) => _collector.global.add(c))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all classes for a specific route (termasuk global)
|
|
77
|
+
*/
|
|
78
|
+
export function getRouteClasses(route: string): Set<string> {
|
|
79
|
+
const result = new Set<string>(_collector.global)
|
|
80
|
+
|
|
81
|
+
// Tambahkan classes dari semua file yang terkait route ini
|
|
82
|
+
const routeFiles = _collector.routes.get(route) ?? new Set()
|
|
83
|
+
for (const filepath of routeFiles) {
|
|
84
|
+
const fileClasses = _collector.files.get(filepath) ?? new Set()
|
|
85
|
+
fileClasses.forEach((c) => result.add(c))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get all routes yang sudah ter-register
|
|
93
|
+
*/
|
|
94
|
+
export function getAllRoutes(): string[] {
|
|
95
|
+
return Array.from(_collector.routes.keys()).sort()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get complete map (untuk build-time generation)
|
|
100
|
+
*/
|
|
101
|
+
export function getCollector(): RouteClassMap {
|
|
102
|
+
return _collector
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reset collector (start of each build)
|
|
107
|
+
*/
|
|
108
|
+
export function resetCollector(): void {
|
|
109
|
+
_collector = {
|
|
110
|
+
files: new Map(),
|
|
111
|
+
routes: new Map(),
|
|
112
|
+
global: new Set(),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
// File → Route mapping
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Konversi filepath ke Next.js App Router route.
|
|
122
|
+
*
|
|
123
|
+
* /src/app/page.tsx → /
|
|
124
|
+
* /src/app/about/page.tsx → /about
|
|
125
|
+
* /src/app/dashboard/page.tsx → /dashboard
|
|
126
|
+
* /src/components/Button.tsx → null (shared component, goes to global)
|
|
127
|
+
* /src/app/layout.tsx → __layout (global)
|
|
128
|
+
*/
|
|
129
|
+
export function fileToRoute(filepath: string): string | null {
|
|
130
|
+
const normalized = filepath.replace(/\\/g, "/")
|
|
131
|
+
|
|
132
|
+
// Layout files → global
|
|
133
|
+
if (
|
|
134
|
+
normalized.includes("/layout.") ||
|
|
135
|
+
normalized.includes("/loading.") ||
|
|
136
|
+
normalized.includes("/error.")
|
|
137
|
+
) {
|
|
138
|
+
return "__global"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Page files in App Router
|
|
142
|
+
const pageMatch = normalized.match(/\/app\/(.+?)\/page\.[tj]sx?$/)
|
|
143
|
+
if (pageMatch) return `/${pageMatch[1]}`
|
|
144
|
+
|
|
145
|
+
const rootPage = normalized.match(/\/app\/page\.[tj]sx?$/)
|
|
146
|
+
if (rootPage) return "/"
|
|
147
|
+
|
|
148
|
+
// Pages Router
|
|
149
|
+
const pagesMatch = normalized.match(/\/pages\/(.+?)\.[tj]sx?$/)
|
|
150
|
+
if (pagesMatch) {
|
|
151
|
+
const route = pagesMatch[1].replace(/\/index$/, "")
|
|
152
|
+
return `/${route}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Shared components → global
|
|
156
|
+
if (
|
|
157
|
+
normalized.includes("/components/") ||
|
|
158
|
+
normalized.includes("/ui/") ||
|
|
159
|
+
normalized.includes("/shared/")
|
|
160
|
+
) {
|
|
161
|
+
return "__global"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
// Summary for logging
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export function getCollectorSummary(): string {
|
|
172
|
+
const routes = getAllRoutes()
|
|
173
|
+
const totalFiles = _collector.files.size
|
|
174
|
+
const totalGlobal = _collector.global.size
|
|
175
|
+
|
|
176
|
+
const lines = [
|
|
177
|
+
`[tailwind-styled-v4] Route CSS Summary:`,
|
|
178
|
+
` Files processed: ${totalFiles}`,
|
|
179
|
+
` Global classes: ${totalGlobal}`,
|
|
180
|
+
` Routes found: ${routes.length}`,
|
|
181
|
+
...routes.map((r) => {
|
|
182
|
+
const cls = getRouteClasses(r).size
|
|
183
|
+
return ` ${r} → ${cls} classes`
|
|
184
|
+
}),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
return lines.join("\n")
|
|
188
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwind-styled-v4 — RSC Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Inti dari RSC-Aware upgrade.
|
|
5
|
+
* Menganalisis setiap file untuk menentukan:
|
|
6
|
+
* - Server Component atau Client Component
|
|
7
|
+
* - Variant mana yang bisa di-resolve di server
|
|
8
|
+
* - Class mana yang membutuhkan client runtime
|
|
9
|
+
* - Auto client boundary injection
|
|
10
|
+
*
|
|
11
|
+
* Hasilnya:
|
|
12
|
+
* - Server Component → pure static className, zero JS ke client
|
|
13
|
+
* - Client Component → tetap seperti biasa dengan lookup table
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// RSC Analysis Types
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export type ComponentEnv = "server" | "client" | "auto"
|
|
21
|
+
|
|
22
|
+
export interface RscAnalysis {
|
|
23
|
+
/** File ini adalah server component */
|
|
24
|
+
isServer: boolean
|
|
25
|
+
/** File ini butuh "use client" directive */
|
|
26
|
+
needsClientDirective: boolean
|
|
27
|
+
/** Alasan butuh client */
|
|
28
|
+
clientReasons: string[]
|
|
29
|
+
/** Classes yang membutuhkan client interaction */
|
|
30
|
+
interactiveClasses: string[]
|
|
31
|
+
/** Apakah semua variants bisa di-resolve statically di server */
|
|
32
|
+
canStaticResolveVariants: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Interactive class patterns — butuh client-side event handling
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** CSS classes yang TIDAK butuh JS — tetap bisa di server */
|
|
40
|
+
const CSS_INTERACTIVE_OK = [
|
|
41
|
+
/^hover:/, // CSS :hover — no JS needed
|
|
42
|
+
/^focus:/, // CSS :focus — no JS needed
|
|
43
|
+
/^focus-within:/, // CSS :focus-within
|
|
44
|
+
/^focus-visible:/, // CSS :focus-visible
|
|
45
|
+
/^active:/, // CSS :active
|
|
46
|
+
/^group-hover:/, // Tailwind group variant — CSS only
|
|
47
|
+
/^group-focus:/, // CSS only
|
|
48
|
+
/^peer-/, // Tailwind peer — CSS only
|
|
49
|
+
/^first:/, // CSS :first-child
|
|
50
|
+
/^last:/, // CSS :last-child
|
|
51
|
+
/^odd:/, // CSS :nth-child(odd)
|
|
52
|
+
/^even:/, // CSS :nth-child(even)
|
|
53
|
+
/^disabled:/, // CSS :disabled
|
|
54
|
+
/^placeholder:/, // CSS ::placeholder
|
|
55
|
+
/^dark:/, // CSS @media prefers-color-scheme
|
|
56
|
+
/^print:/, // CSS @media print
|
|
57
|
+
/^md:|^sm:|^lg:|^xl:|^2xl:/, // Responsive breakpoints — CSS only
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
/** Patterns yang BENAR-BENAR butuh JS runtime */
|
|
61
|
+
const REQUIRES_JS_PATTERNS = [
|
|
62
|
+
// React hooks
|
|
63
|
+
/\buseState\b/,
|
|
64
|
+
/\buseEffect\b/,
|
|
65
|
+
/\buseRef\b/,
|
|
66
|
+
/\buseCallback\b/,
|
|
67
|
+
/\buseMemo\b/,
|
|
68
|
+
/\buseReducer\b/,
|
|
69
|
+
/\buseContext\b/,
|
|
70
|
+
// Event handlers
|
|
71
|
+
/\bon[A-Z][a-zA-Z]+\s*[=:]/, // onClick=, onMouseEnter:, etc.
|
|
72
|
+
// Browser APIs
|
|
73
|
+
/\bwindow\./,
|
|
74
|
+
/\bdocument\./,
|
|
75
|
+
/\blocalStorage\b/,
|
|
76
|
+
/\bsessionStorage\b/,
|
|
77
|
+
// Dynamic imports
|
|
78
|
+
/import\s*\(/,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
// Main analyzer
|
|
83
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export function analyzeFile(source: string, _filename = ""): RscAnalysis {
|
|
86
|
+
const clientReasons: string[] = []
|
|
87
|
+
const interactiveClasses: string[] = []
|
|
88
|
+
|
|
89
|
+
// 1. Explicit "use client" directive
|
|
90
|
+
const hasClientDirective =
|
|
91
|
+
source.trimStart().startsWith('"use client"') || source.trimStart().startsWith("'use client'")
|
|
92
|
+
|
|
93
|
+
if (hasClientDirective) {
|
|
94
|
+
clientReasons.push("explicit 'use client' directive")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. React hooks → needs client
|
|
98
|
+
for (const pattern of REQUIRES_JS_PATTERNS) {
|
|
99
|
+
if (pattern.test(source)) {
|
|
100
|
+
const match = source.match(pattern)
|
|
101
|
+
if (match) clientReasons.push(`uses ${match[0].trim()}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 3. Check for tw.server.* usage — force server
|
|
106
|
+
const hasServerMarker = source.includes("tw.server.")
|
|
107
|
+
|
|
108
|
+
// 4. Collect interactive classes from tw templates
|
|
109
|
+
const templateRe = /\btw\.(?:server\.)?(\w+)`((?:[^`\\]|\\.)*)`/g
|
|
110
|
+
const _objectRe = /\btw\.(?:server\.)?(\w+)\(\s*(\{[\s\S]*?\})\s*\)/g
|
|
111
|
+
let m: RegExpExecArray | null
|
|
112
|
+
|
|
113
|
+
while ((m = templateRe.exec(source)) !== null) {
|
|
114
|
+
const classes = m[2]
|
|
115
|
+
// CSS-only interaction is fine for server, skip
|
|
116
|
+
// But collect them for reference
|
|
117
|
+
const parts = classes.split(/\s+/).filter(Boolean)
|
|
118
|
+
for (const cls of parts) {
|
|
119
|
+
const isOk = CSS_INTERACTIVE_OK.some((re) => re.test(cls))
|
|
120
|
+
if (!isOk && /^[a-z-]+:/.test(cls)) {
|
|
121
|
+
interactiveClasses.push(cls)
|
|
122
|
+
clientReasons.push(`uses JS-interactive class: ${cls}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const needsClientDirective = !hasServerMarker && (hasClientDirective || clientReasons.length > 0)
|
|
128
|
+
|
|
129
|
+
const isServer = !needsClientDirective || hasServerMarker
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
isServer,
|
|
133
|
+
needsClientDirective,
|
|
134
|
+
clientReasons: [...new Set(clientReasons)],
|
|
135
|
+
interactiveClasses: [...new Set(interactiveClasses)],
|
|
136
|
+
canStaticResolveVariants: isServer,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
// Server-side static variant resolution
|
|
142
|
+
//
|
|
143
|
+
// Ketika sebuah file adalah server component dan variant value diketahui
|
|
144
|
+
// di compile time (literal string), compiler bisa langsung inline className.
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
export interface StaticVariantUsage {
|
|
148
|
+
/** Variant prop values yang ditemukan di JSX — bisa di-resolve di server */
|
|
149
|
+
resolved: Record<string, string>
|
|
150
|
+
/** Variant props yang dinamis — butuh runtime */
|
|
151
|
+
dynamic: string[]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Deteksi penggunaan variant dalam JSX:
|
|
156
|
+
* <Button variant="primary"/> → dapat di-resolve statically
|
|
157
|
+
* <Button variant={userVariant}/> → dinamis, butuh runtime
|
|
158
|
+
*/
|
|
159
|
+
export function analyzeVariantUsage(
|
|
160
|
+
source: string,
|
|
161
|
+
componentName: string,
|
|
162
|
+
variantKeys: string[]
|
|
163
|
+
): StaticVariantUsage {
|
|
164
|
+
const resolved: Record<string, string> = {}
|
|
165
|
+
const dynamic: string[] = []
|
|
166
|
+
|
|
167
|
+
for (const key of variantKeys) {
|
|
168
|
+
// Static: variant="primary"
|
|
169
|
+
const staticRe = new RegExp(`<${componentName}[^>]*\\b${key}=["']([^"']+)["'][^>]*>`, "g")
|
|
170
|
+
// Dynamic: variant={someVar}
|
|
171
|
+
const dynamicRe = new RegExp(`<${componentName}[^>]*\\b${key}=\\{[^"'][^}]*\\}[^>]*>`, "g")
|
|
172
|
+
|
|
173
|
+
const staticMatch = source.match(staticRe)
|
|
174
|
+
const dynamicMatch = source.match(dynamicRe)
|
|
175
|
+
|
|
176
|
+
if (dynamicMatch) {
|
|
177
|
+
dynamic.push(key)
|
|
178
|
+
} else if (staticMatch) {
|
|
179
|
+
const valMatch = staticMatch[0].match(new RegExp(`${key}=["']([^"']+)["']`))
|
|
180
|
+
if (valMatch) resolved[key] = valMatch[1]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { resolved, dynamic }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Untuk server component dengan variant usage statically known,
|
|
189
|
+
* resolve langsung ke className string — nol runtime.
|
|
190
|
+
*
|
|
191
|
+
* Input:
|
|
192
|
+
* base = "px-4 py-2"
|
|
193
|
+
* table = { variant: { primary: "px-4 py-2 bg-blue-500" } }
|
|
194
|
+
* resolved = { variant: "primary" }
|
|
195
|
+
*
|
|
196
|
+
* Output:
|
|
197
|
+
* "px-4 py-2 bg-blue-500" ← langsung inline di server
|
|
198
|
+
*/
|
|
199
|
+
export function resolveServerVariant(
|
|
200
|
+
base: string,
|
|
201
|
+
table: Record<string, Record<string, string>>,
|
|
202
|
+
defaults: Record<string, string>,
|
|
203
|
+
resolved: Record<string, string>
|
|
204
|
+
): string {
|
|
205
|
+
const parts: string[] = [base]
|
|
206
|
+
|
|
207
|
+
for (const key in table) {
|
|
208
|
+
const val = resolved[key] ?? defaults[key]
|
|
209
|
+
if (val && table[key][val]) {
|
|
210
|
+
parts.push(table[key][val])
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Dedupe dengan last-wins
|
|
215
|
+
const seen = new Map<string, string>()
|
|
216
|
+
for (const part of parts) {
|
|
217
|
+
for (const cls of part.split(/\s+/).filter(Boolean)) {
|
|
218
|
+
const prefix = cls.replace(/^(?:[\w-]+:)*/, "").split("-")[0]
|
|
219
|
+
seen.set(prefix, cls)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return Array.from(seen.values()).join(" ")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
// Client boundary auto-injection helpers
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
export function injectClientDirective(code: string): string {
|
|
231
|
+
if (code.startsWith('"use client"') || code.startsWith("'use client'")) {
|
|
232
|
+
return code
|
|
233
|
+
}
|
|
234
|
+
return `"use client";\n${code}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function injectServerOnlyComment(code: string): string {
|
|
238
|
+
// Hint untuk bundler — RSC optimizer bisa tree-shake client deps
|
|
239
|
+
return `/* @tw-server-only */\n${code}`
|
|
240
|
+
}
|