arcway 0.1.16 → 0.1.17
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 +1 -1
- package/server/pages/build-cache.js +192 -1
- package/server/pages/build-css.js +30 -15
- package/server/pages/build.js +13 -2
- package/server/pages/class-token-scan.js +71 -0
package/package.json
CHANGED
|
@@ -91,6 +91,18 @@ async function cpAtomic(src, dst) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// Best-effort LRU timestamp bump. File mtime is what enforceCacheBudget orders
|
|
95
|
+
// by, so this is how we record "recently used" for cache hits. Swallow errors —
|
|
96
|
+
// a concurrent evictor may have removed the file already; that's fine.
|
|
97
|
+
async function touchMeta(metaPath) {
|
|
98
|
+
const now = new Date();
|
|
99
|
+
try {
|
|
100
|
+
await fs.utimes(metaPath, now, now);
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore: entry may have been evicted concurrently.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
async function lookupBundle({ rootDir, entryPath, configHash, kind }) {
|
|
95
107
|
const bucket = bucketDir(rootDir, kind);
|
|
96
108
|
const key = entryKey(entryPath);
|
|
@@ -113,6 +125,7 @@ async function lookupBundle({ rootDir, entryPath, configHash, kind }) {
|
|
|
113
125
|
} catch {
|
|
114
126
|
return { hit: false, key, bucket, metaPath, jsPath, mapPath };
|
|
115
127
|
}
|
|
128
|
+
await touchMeta(metaPath);
|
|
116
129
|
return { hit: true, key, bucket, metaPath, jsPath, mapPath };
|
|
117
130
|
}
|
|
118
131
|
|
|
@@ -194,10 +207,26 @@ async function buildWithCache({
|
|
|
194
207
|
return { cacheHit: false, metafile: result.metafile };
|
|
195
208
|
}
|
|
196
209
|
|
|
210
|
+
// Canonical, order-independent hash of a virtualInputs list so stored/current
|
|
211
|
+
// sets can be compared without worrying about caller ordering.
|
|
212
|
+
function hashVirtualInputs(virtualInputs) {
|
|
213
|
+
if (!virtualInputs || virtualInputs.length === 0) return '';
|
|
214
|
+
const sorted = [...virtualInputs]
|
|
215
|
+
.map((v) => ({ name: v.name, digest: v.digest }))
|
|
216
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
217
|
+
return sha256(JSON.stringify(sorted));
|
|
218
|
+
}
|
|
219
|
+
|
|
197
220
|
// Multi-file cache for bundles with several output files (client build with
|
|
198
221
|
// code-splitting). Each cache entry lives under <bucket>/<coarseKey>/ and the
|
|
199
222
|
// index is a sidecar <bucket>/<coarseKey>.meta.json.
|
|
200
|
-
|
|
223
|
+
//
|
|
224
|
+
// `virtualInputs` (optional) are caller-computed digests that can't be
|
|
225
|
+
// represented as a file path — e.g. the set of class-name tokens extracted
|
|
226
|
+
// from a source tree. Each entry is `{ name, digest }`. The caller must
|
|
227
|
+
// re-compute and pass current values on every lookup; the stored and current
|
|
228
|
+
// digest sets must match exactly for a hit.
|
|
229
|
+
async function lookupMultiFileCache({ rootDir, kind, coarseKey, virtualInputs }) {
|
|
201
230
|
const bucket = bucketDir(rootDir, kind);
|
|
202
231
|
const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
|
|
203
232
|
const cacheDir = path.join(bucket, coarseKey);
|
|
@@ -207,6 +236,14 @@ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
|
|
|
207
236
|
if (currentHash === null || currentHash !== meta.inputsHash) {
|
|
208
237
|
return { hit: false, bucket, cacheDir, metaPath };
|
|
209
238
|
}
|
|
239
|
+
// Compare virtual-input digest sets. If the stored entry has virtualInputs
|
|
240
|
+
// but the caller omitted them, treat as a miss — a silent hit would serve
|
|
241
|
+
// stale output for whatever derived state the virtual digest tracks.
|
|
242
|
+
const storedVirtualHash = meta.virtualInputsHash ?? '';
|
|
243
|
+
const currentVirtualHash = hashVirtualInputs(virtualInputs);
|
|
244
|
+
if (storedVirtualHash !== currentVirtualHash) {
|
|
245
|
+
return { hit: false, bucket, cacheDir, metaPath };
|
|
246
|
+
}
|
|
210
247
|
// Sanity-check every recorded output file still exists in the cache dir.
|
|
211
248
|
for (const rel of meta.outputs) {
|
|
212
249
|
try {
|
|
@@ -215,6 +252,7 @@ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
|
|
|
215
252
|
return { hit: false, bucket, cacheDir, metaPath };
|
|
216
253
|
}
|
|
217
254
|
}
|
|
255
|
+
await touchMeta(metaPath);
|
|
218
256
|
return { hit: true, bucket, cacheDir, metaPath, meta };
|
|
219
257
|
}
|
|
220
258
|
|
|
@@ -236,6 +274,7 @@ async function storeMultiFileCache({
|
|
|
236
274
|
destDir,
|
|
237
275
|
outputs,
|
|
238
276
|
inputs,
|
|
277
|
+
virtualInputs,
|
|
239
278
|
metadata,
|
|
240
279
|
}) {
|
|
241
280
|
const cacheDir = path.join(bucket, coarseKey);
|
|
@@ -250,16 +289,166 @@ async function storeMultiFileCache({
|
|
|
250
289
|
}),
|
|
251
290
|
);
|
|
252
291
|
const inputsHash = await hashInputs(inputs);
|
|
292
|
+
const virtualInputsHash = hashVirtualInputs(virtualInputs);
|
|
253
293
|
const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
|
|
254
294
|
await writeJsonAtomic(metaPath, {
|
|
255
295
|
inputs,
|
|
256
296
|
inputsHash,
|
|
297
|
+
virtualInputs: virtualInputs ?? [],
|
|
298
|
+
virtualInputsHash,
|
|
257
299
|
outputs,
|
|
258
300
|
metadata,
|
|
259
301
|
mtime: Date.now(),
|
|
260
302
|
});
|
|
261
303
|
}
|
|
262
304
|
|
|
305
|
+
// Default cap for the on-disk cache. Override with ARCWAY_PAGES_CACHE_MAX_BYTES
|
|
306
|
+
// (bytes as an integer). Anything over the cap is LRU-evicted after each store.
|
|
307
|
+
const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024;
|
|
308
|
+
|
|
309
|
+
// Newly-written entries are protected from eviction for this many milliseconds
|
|
310
|
+
// to avoid racing with in-flight stores from sibling forks. 5s comfortably
|
|
311
|
+
// exceeds a typical per-entry store (cpAtomic + writeJsonAtomic).
|
|
312
|
+
const DEFAULT_CACHE_GRACE_PERIOD_MS = 5000;
|
|
313
|
+
|
|
314
|
+
function cacheMaxBytes() {
|
|
315
|
+
const raw = Number(process.env.ARCWAY_PAGES_CACHE_MAX_BYTES);
|
|
316
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CACHE_MAX_BYTES;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function statOrNull(filePath) {
|
|
320
|
+
try {
|
|
321
|
+
return await fs.stat(filePath);
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Recursively measure a directory's total size and the most-recent mtime of any
|
|
328
|
+
// file/subdir inside it. Used for multi-file cache entries whose body lives in a
|
|
329
|
+
// sibling dir. Returns {size: 0, mtime: 0} when the dir is missing.
|
|
330
|
+
async function measureDir(dir) {
|
|
331
|
+
let size = 0;
|
|
332
|
+
let mtime = 0;
|
|
333
|
+
let items;
|
|
334
|
+
try {
|
|
335
|
+
items = await fs.readdir(dir, { withFileTypes: true });
|
|
336
|
+
} catch {
|
|
337
|
+
return { size, mtime };
|
|
338
|
+
}
|
|
339
|
+
for (const item of items) {
|
|
340
|
+
const abs = path.join(dir, item.name);
|
|
341
|
+
if (item.isDirectory()) {
|
|
342
|
+
const sub = await measureDir(abs);
|
|
343
|
+
size += sub.size;
|
|
344
|
+
if (sub.mtime > mtime) mtime = sub.mtime;
|
|
345
|
+
} else {
|
|
346
|
+
const s = await statOrNull(abs);
|
|
347
|
+
if (s) {
|
|
348
|
+
size += s.size;
|
|
349
|
+
if (s.mtimeMs > mtime) mtime = s.mtimeMs;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { size, mtime };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Enumerate every cache entry across all kinds. Each entry is identified by its
|
|
357
|
+
// sidecar `<key>.meta.json` file. Single-file entries have an accompanying
|
|
358
|
+
// `<key>.js` (+ optional `.js.map`); multi-file entries have a `<coarseKey>/`
|
|
359
|
+
// dir. We collect paths for both shapes so removal is unconditional.
|
|
360
|
+
async function collectCacheEntries(rootDir) {
|
|
361
|
+
const root = cacheRoot(rootDir);
|
|
362
|
+
let kindDirs;
|
|
363
|
+
try {
|
|
364
|
+
kindDirs = await fs.readdir(root, { withFileTypes: true });
|
|
365
|
+
} catch {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
const entries = [];
|
|
369
|
+
for (const kindEnt of kindDirs) {
|
|
370
|
+
if (!kindEnt.isDirectory()) continue;
|
|
371
|
+
const bucket = path.join(root, kindEnt.name);
|
|
372
|
+
let files;
|
|
373
|
+
try {
|
|
374
|
+
files = await fs.readdir(bucket);
|
|
375
|
+
} catch {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
for (const file of files) {
|
|
379
|
+
if (!file.endsWith('.meta.json')) continue;
|
|
380
|
+
const baseKey = file.slice(0, -'.meta.json'.length);
|
|
381
|
+
const metaPath = path.join(bucket, file);
|
|
382
|
+
const jsPath = path.join(bucket, `${baseKey}.js`);
|
|
383
|
+
const mapPath = path.join(bucket, `${baseKey}.js.map`);
|
|
384
|
+
const dirPath = path.join(bucket, baseKey);
|
|
385
|
+
|
|
386
|
+
const metaStat = await statOrNull(metaPath);
|
|
387
|
+
if (!metaStat) continue;
|
|
388
|
+
const jsStat = await statOrNull(jsPath);
|
|
389
|
+
const mapStat = await statOrNull(mapPath);
|
|
390
|
+
const dirInfo = await measureDir(dirPath);
|
|
391
|
+
|
|
392
|
+
const size = metaStat.size + (jsStat?.size ?? 0) + (mapStat?.size ?? 0) + dirInfo.size;
|
|
393
|
+
// Use the most recent mtime across meta + dir contents. A concurrent
|
|
394
|
+
// multi-file store rewrites `.meta.json` last but populates the dir
|
|
395
|
+
// first, so dir mtime can be newer than meta mtime during a store.
|
|
396
|
+
const mtime = Math.max(metaStat.mtimeMs, dirInfo.mtime);
|
|
397
|
+
entries.push({ metaPath, jsPath, mapPath, dirPath, size, mtime });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return entries;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function removeCacheEntry(entry) {
|
|
404
|
+
await Promise.allSettled([
|
|
405
|
+
fs.rm(entry.metaPath, { force: true }),
|
|
406
|
+
fs.rm(entry.jsPath, { force: true }),
|
|
407
|
+
fs.rm(entry.mapPath, { force: true }),
|
|
408
|
+
fs.rm(entry.dirPath, { recursive: true, force: true }),
|
|
409
|
+
]);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Enforce a byte-budget over the on-disk cache. Oldest (by filesystem mtime)
|
|
413
|
+
// entries are evicted first; entries whose mtime is within `gracePeriodMs` of
|
|
414
|
+
// `now` are protected so an in-flight concurrent store isn't yanked out from
|
|
415
|
+
// under a sibling fork. Lock-free and idempotent: multiple forks may run this
|
|
416
|
+
// concurrently and at worst slightly over-evict, which self-corrects on the
|
|
417
|
+
// next cold build.
|
|
418
|
+
async function enforceCacheBudget({
|
|
419
|
+
rootDir,
|
|
420
|
+
maxBytes = cacheMaxBytes(),
|
|
421
|
+
gracePeriodMs = DEFAULT_CACHE_GRACE_PERIOD_MS,
|
|
422
|
+
now = Date.now(),
|
|
423
|
+
} = {}) {
|
|
424
|
+
const entries = await collectCacheEntries(rootDir);
|
|
425
|
+
const totalSize = entries.reduce((s, e) => s + e.size, 0);
|
|
426
|
+
if (totalSize <= maxBytes) {
|
|
427
|
+
return { evicted: 0, kept: entries.length, bytesFreed: 0, bytesRemaining: totalSize };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const candidates = entries
|
|
431
|
+
.filter((e) => now - e.mtime >= gracePeriodMs)
|
|
432
|
+
.sort((a, b) => a.mtime - b.mtime);
|
|
433
|
+
|
|
434
|
+
let remaining = totalSize;
|
|
435
|
+
let freed = 0;
|
|
436
|
+
let evicted = 0;
|
|
437
|
+
for (const cand of candidates) {
|
|
438
|
+
if (remaining <= maxBytes) break;
|
|
439
|
+
await removeCacheEntry(cand);
|
|
440
|
+
remaining -= cand.size;
|
|
441
|
+
freed += cand.size;
|
|
442
|
+
evicted += 1;
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
evicted,
|
|
446
|
+
kept: entries.length - evicted,
|
|
447
|
+
bytesFreed: freed,
|
|
448
|
+
bytesRemaining: remaining,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
263
452
|
// Walk every cache entry in <rootDir>/node_modules/.cache/arcway-pages/<kind>
|
|
264
453
|
// and drop any whose recorded inputs no longer hash to the stored value (or
|
|
265
454
|
// whose inputs are gone). Used by tests that intentionally mutate fixture
|
|
@@ -310,6 +499,8 @@ export {
|
|
|
310
499
|
buildWithCache,
|
|
311
500
|
computeConfigHash,
|
|
312
501
|
cacheRoot,
|
|
502
|
+
DEFAULT_CACHE_MAX_BYTES,
|
|
503
|
+
enforceCacheBudget,
|
|
313
504
|
entryKey,
|
|
314
505
|
ESBUILD_VERSION,
|
|
315
506
|
sha256,
|
|
@@ -11,10 +11,16 @@ import {
|
|
|
11
11
|
restoreMultiFileCache,
|
|
12
12
|
storeMultiFileCache,
|
|
13
13
|
} from './build-cache.js';
|
|
14
|
+
import { hashClassTokens } from './class-token-scan.js';
|
|
14
15
|
|
|
15
|
-
// Shallow-walk
|
|
16
|
-
//
|
|
17
|
-
|
|
16
|
+
// Shallow-walk pagesDir for the CSS files tailwind will scan; these are
|
|
17
|
+
// tracked as real file inputs so any byte-level edit invalidates the cache.
|
|
18
|
+
// JSX/TS/etc. are not included here — they go through hashClassTokens(), which
|
|
19
|
+
// produces a virtual-input digest that only busts the cache when the set of
|
|
20
|
+
// string-literal tokens changes. That's a superset of every class name
|
|
21
|
+
// tailwind could emit, and ignores unrelated edits (logic, whitespace,
|
|
22
|
+
// comments) that can't affect the generated CSS.
|
|
23
|
+
async function collectCssFiles(pagesDir) {
|
|
18
24
|
const out = [];
|
|
19
25
|
async function walk(dir) {
|
|
20
26
|
let entries;
|
|
@@ -28,10 +34,8 @@ async function collectTailwindSources(pagesDir) {
|
|
|
28
34
|
if (e.isDirectory()) {
|
|
29
35
|
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
30
36
|
await walk(full);
|
|
31
|
-
} else if (e.isFile()) {
|
|
32
|
-
|
|
33
|
-
out.push(full);
|
|
34
|
-
}
|
|
37
|
+
} else if (e.isFile() && /\.css$/i.test(e.name)) {
|
|
38
|
+
out.push(full);
|
|
35
39
|
}
|
|
36
40
|
}
|
|
37
41
|
}
|
|
@@ -60,16 +64,26 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
|
|
|
60
64
|
rootDir = rootDir ?? path.dirname(outDir);
|
|
61
65
|
const coarseKey = computeCssCoarseKey({ minify, fontFaceCss, stylesPath });
|
|
62
66
|
|
|
63
|
-
//
|
|
67
|
+
// File inputs: stylesPath + every CSS file under pagesDir. These are hashed
|
|
68
|
+
// by full content — any byte change should invalidate the cache because raw
|
|
69
|
+
// CSS ends up in the output verbatim.
|
|
64
70
|
const inputs = [];
|
|
65
|
-
if (stylesPath)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
if (stylesPath) inputs.push(path.resolve(stylesPath));
|
|
72
|
+
const cssFiles = await collectCssFiles(pagesDir);
|
|
73
|
+
for (const f of cssFiles) inputs.push(f);
|
|
74
|
+
|
|
75
|
+
// Virtual input: a digest over every string-literal token found in the
|
|
76
|
+
// JSX/TS source tree under pagesDir. Tailwind's class-name emission depends
|
|
77
|
+
// only on which tokens appear in scanned files — not on surrounding code
|
|
78
|
+
// structure — so the digest captures the only source signal that actually
|
|
79
|
+
// matters for CSS output. Editing handler logic, comments, or whitespace
|
|
80
|
+
// leaves this digest unchanged and the CSS cache is reused.
|
|
81
|
+
const classTokensDigest = await hashClassTokens(pagesDir);
|
|
82
|
+
const virtualInputs = [
|
|
83
|
+
{ name: `${path.resolve(pagesDir)}::class-tokens`, digest: classTokensDigest },
|
|
84
|
+
];
|
|
71
85
|
|
|
72
|
-
const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey });
|
|
86
|
+
const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey, virtualInputs });
|
|
73
87
|
if (lookup.hit) {
|
|
74
88
|
await restoreMultiFileCache({
|
|
75
89
|
cacheDir: lookup.cacheDir,
|
|
@@ -114,6 +128,7 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
|
|
|
114
128
|
destDir: clientDir,
|
|
115
129
|
outputs: [cssFileName],
|
|
116
130
|
inputs,
|
|
131
|
+
virtualInputs,
|
|
117
132
|
metadata: { cssBundleRel },
|
|
118
133
|
});
|
|
119
134
|
|
package/server/pages/build.js
CHANGED
|
@@ -20,6 +20,7 @@ import { buildCssBundle } from './build-css.js';
|
|
|
20
20
|
import { generateManifest } from './build-manifest.js';
|
|
21
21
|
import { resolveFonts } from './fonts.js';
|
|
22
22
|
import { buildHmrRuntimeBundle } from './hmr.js';
|
|
23
|
+
import { enforceCacheBudget } from './build-cache.js';
|
|
23
24
|
async function buildPages(options) {
|
|
24
25
|
const { rootDir } = options;
|
|
25
26
|
const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
|
|
@@ -107,6 +108,13 @@ async function buildPages(options) {
|
|
|
107
108
|
JSON.stringify(manifest, null, 2),
|
|
108
109
|
);
|
|
109
110
|
await atomicSwap(outDir, buildDir);
|
|
111
|
+
// Keep the on-disk cache bounded. Fire-and-forget so the build returns
|
|
112
|
+
// immediately; eviction is idempotent across concurrent builds and any
|
|
113
|
+
// failure is non-fatal (worst case the cache grows past the cap until the
|
|
114
|
+
// next build). Tracked via `cacheEvictions` for test quiescence.
|
|
115
|
+
const eviction = enforceCacheBudget({ rootDir }).catch(() => {});
|
|
116
|
+
cacheEvictions.add(eviction);
|
|
117
|
+
eviction.finally(() => cacheEvictions.delete(eviction));
|
|
110
118
|
const clientMetafile =
|
|
111
119
|
devMode && clientResult.metafile
|
|
112
120
|
? rewriteMetafilePaths(clientResult.metafile, buildDir, outDir)
|
|
@@ -136,9 +144,12 @@ async function buildPages(options) {
|
|
|
136
144
|
// Tracks fire-and-forget cleanup promises from failed builds so tests can
|
|
137
145
|
// await `buildPagesIdle()` and see a clean `.build` dir.
|
|
138
146
|
const pendingCleanups = new Set();
|
|
147
|
+
// Tracks fire-and-forget cache-eviction promises from successful builds so
|
|
148
|
+
// tests can await full quiescence and assert on post-eviction cache state.
|
|
149
|
+
const cacheEvictions = new Set();
|
|
139
150
|
async function buildPagesIdle() {
|
|
140
|
-
while (pendingCleanups.size > 0) {
|
|
141
|
-
await Promise.allSettled([...pendingCleanups]);
|
|
151
|
+
while (pendingCleanups.size > 0 || cacheEvictions.size > 0) {
|
|
152
|
+
await Promise.allSettled([...pendingCleanups, ...cacheEvictions]);
|
|
142
153
|
}
|
|
143
154
|
}
|
|
144
155
|
// Serialize concurrent swaps targeting the same outDir. Without this, two
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const SOURCE_FILE_PATTERN = /\.(jsx?|tsx?|mjs|cjs)$/i;
|
|
6
|
+
|
|
7
|
+
// Match the *content* of any string literal — double-quoted, single-quoted, or
|
|
8
|
+
// backtick template. Escape-aware so `\"` doesn't end a double-quoted string
|
|
9
|
+
// prematurely. Template interpolation `${...}` is captured verbatim inside the
|
|
10
|
+
// backtick group; we don't try to parse it out, so interpolation syntax leaks
|
|
11
|
+
// into tokens. That's a benign false-positive signal — if the expression's
|
|
12
|
+
// source changes, the digest changes; tailwind's own oxide scanner has the
|
|
13
|
+
// same boundary limitation.
|
|
14
|
+
const STRING_LITERAL_RE =
|
|
15
|
+
/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|`((?:[^`\\]|\\.)*)`/g;
|
|
16
|
+
|
|
17
|
+
// Extract every whitespace-separated token found inside any string literal in
|
|
18
|
+
// `content`. Returns a Set so callers can take a union across files. We don't
|
|
19
|
+
// try to filter to "looks-like-a-tailwind-class" shapes because tailwind v4
|
|
20
|
+
// accepts arbitrary-value classes like `hover:bg-[#fff]` whose superset of
|
|
21
|
+
// allowed characters is broad. Over-inclusion is safe — an extra token only
|
|
22
|
+
// causes unnecessary cache busts on unrelated edits, not stale output.
|
|
23
|
+
function extractClassTokens(content) {
|
|
24
|
+
const tokens = new Set();
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = STRING_LITERAL_RE.exec(content)) !== null) {
|
|
27
|
+
const body = match[1] ?? match[2] ?? match[3] ?? '';
|
|
28
|
+
for (const part of body.split(/\s+/)) {
|
|
29
|
+
if (part.length > 0) tokens.add(part);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return tokens;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function walkSources(dir, onFile) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.code === 'ENOENT') return;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
const full = path.join(dir, entry.name);
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
47
|
+
await walkSources(full, onFile);
|
|
48
|
+
} else if (entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name)) {
|
|
49
|
+
await onFile(full);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Union every class-name-shaped token across every JSX/TS source under
|
|
55
|
+
// `pagesDir`, sort, and hash. Sort so the digest is order-independent —
|
|
56
|
+
// adding/removing files only affects the digest via the *set* of tokens, not
|
|
57
|
+
// filesystem enumeration order, which prevents false busts from filesystem
|
|
58
|
+
// reordering across platforms.
|
|
59
|
+
async function hashClassTokens(pagesDir) {
|
|
60
|
+
const tokens = new Set();
|
|
61
|
+
await walkSources(pagesDir, async (filePath) => {
|
|
62
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
63
|
+
for (const token of extractClassTokens(content)) {
|
|
64
|
+
tokens.add(token);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const sorted = [...tokens].sort();
|
|
68
|
+
return crypto.createHash('sha256').update(sorted.join('\n')).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { extractClassTokens, hashClassTokens };
|