@fin14/core 0.1.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.
@@ -0,0 +1,74 @@
1
+ import { BYTES_PER_KB, STRICT_IPV6_LIMIT_BYTES } from '../constants.js'
2
+ import { resolveBudget } from '../glob.js'
3
+
4
+ export function deriveStatusWarnings(results, totalBytes, limitBytes, hasOverride, file) {
5
+ const overBudget = totalBytes > limitBytes
6
+ const warnings = []
7
+
8
+ if (totalBytes > STRICT_IPV6_LIMIT_BYTES && !overBudget && !hasOverride) {
9
+ warnings.push({
10
+ code: 'E13',
11
+ message: `${file}: ${(totalBytes / BYTES_PER_KB).toFixed(1)}kB exceeds IPv6 heuristic (12.2kB)`,
12
+ path: file,
13
+ strictFailure: true,
14
+ })
15
+ }
16
+
17
+ for (const result of results) {
18
+ if (result.external === true) {
19
+ warnings.push({
20
+ code: 'E14',
21
+ message: `external resource counted: ${result.displayPath}`,
22
+ path: result.path,
23
+ strictFailure: true,
24
+ })
25
+ }
26
+ }
27
+
28
+ return warnings
29
+ }
30
+
31
+ export function validate(results, skipped, warnings, config, file) {
32
+ const limitBytes = resolveBudget(file, config)
33
+ const hasOverride = limitBytes !== config.budget.default
34
+ const totalBytes = results.reduce((sum, r) => sum + r.bytes, 0)
35
+ const overBudget = totalBytes > limitBytes
36
+ const unknownBudget = warnings.some(w => w.code === 'E19')
37
+
38
+ const newWarnings = [...warnings, ...deriveStatusWarnings(results, totalBytes, limitBytes, hasOverride, file)]
39
+
40
+ const status = overBudget ? 'fail' : newWarnings.length > 0 ? 'warn' : 'pass'
41
+
42
+ const cutCandidates = computeCutCandidates(results, totalBytes, limitBytes, overBudget)
43
+
44
+ return {
45
+ file,
46
+ totalBytes,
47
+ limitBytes,
48
+ status,
49
+ overBudget,
50
+ unknownBudget,
51
+ resources: results,
52
+ skippedResources: skipped,
53
+ warnings: newWarnings,
54
+ cutCandidates,
55
+ }
56
+ }
57
+
58
+ export function computeCutCandidates(results, totalBytes, limitBytes, overBudget) {
59
+ const sorted = [...results].sort((a, b) => b.bytes - a.bytes)
60
+
61
+ if (overBudget) {
62
+ const overage = totalBytes - limitBytes
63
+ const candidates = []
64
+ let cumulative = 0
65
+ for (const resource of sorted) {
66
+ candidates.push(resource)
67
+ cumulative += resource.bytes
68
+ if (cumulative >= overage) break
69
+ }
70
+ return candidates.length > 0 ? candidates : [sorted[0]]
71
+ } else {
72
+ return sorted.slice(0, 3)
73
+ }
74
+ }
@@ -0,0 +1,23 @@
1
+ import { gzipSync, brotliCompressSync, constants } from 'node:zlib'
2
+ import { GZIP_MIN_LEVEL, GZIP_MAX_LEVEL } from './constants.js'
3
+
4
+ export function compress(content, config) {
5
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content)
6
+ if (config.compression === 'brotli') {
7
+ return brotliCompressSync(buf, {
8
+ params: { [constants.BROTLI_PARAM_QUALITY]: config.compression_level },
9
+ }).length
10
+ }
11
+ const level = Math.min(Math.max(config.compression_level, GZIP_MIN_LEVEL), GZIP_MAX_LEVEL)
12
+ return gzipSync(buf, { level }).length
13
+ }
14
+
15
+ export function gzipLevelClampNotice(budget) {
16
+ if (budget.compression !== 'gzip') return null
17
+ const level = budget.compression_level
18
+ if (level < GZIP_MIN_LEVEL || level > GZIP_MAX_LEVEL) {
19
+ const effective = Math.min(Math.max(level, GZIP_MIN_LEVEL), GZIP_MAX_LEVEL)
20
+ return `compression_level ${level} clamped to ${effective} (gzip range ${GZIP_MIN_LEVEL}–${GZIP_MAX_LEVEL}); use brotli for level 0 or 10–11`
21
+ }
22
+ return null
23
+ }
package/src/config.js ADDED
@@ -0,0 +1,313 @@
1
+ import { readFile, access } from 'node:fs/promises'
2
+ import { parse } from 'smol-toml'
3
+ import { BYTES_PER_KB, COMPRESSION_LEVEL_MAX, COMPRESSION_LEVEL_MIN, DEFAULT_CONFIG, IGNORE_PRESETS } from './constants.js'
4
+
5
+ const SSG_DETECTORS = [
6
+ { files: ['hugo.toml', 'hugo.yaml', 'hugo.json'], dir: 'public' },
7
+ { files: ['.eleventy.js', 'eleventy.config.js', 'eleventy.config.mjs', 'eleventy.config.cjs'], dir: '_site' },
8
+ { files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs', 'astro.config.cjs'], dir: 'dist' },
9
+ { files: ['_config.yml', '_config.toml'], dir: '_site' },
10
+ { files: ['next.config.js', 'next.config.ts', 'next.config.mjs'], dir: 'out' },
11
+ ]
12
+
13
+ async function detectOutputDir(cwd) {
14
+ for (const { files, dir } of SSG_DETECTORS) {
15
+ for (const file of files) {
16
+ try { await access(`${cwd}/${file}`); return dir } catch {}
17
+ }
18
+ }
19
+ return null
20
+ }
21
+
22
+ const KNOWN_KEYS = {
23
+ project: new Set(['output_dir', 'build_command', 'include', 'exclude']),
24
+ budget: new Set(['default', 'compression', 'compression_level', 'cut_candidate_min_bytes', 'baseline_min_delta_bytes', 'overrides', 'ignore', 'ignore_presets']),
25
+ network: new Set(['timeout_ms', 'max_redirects']),
26
+ viewport: new Set(['default', 'mobile', 'desktop']),
27
+ warnings: new Set(['exclude']),
28
+ }
29
+
30
+ const OVERRIDE_KEYS = new Set(['path', 'budget'])
31
+
32
+ function validateGlobList(value, fieldPath) {
33
+ if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) {
34
+ throw new Error(`E04: fin14.toml: ${fieldPath} must be an array of strings`)
35
+ }
36
+ return value
37
+ }
38
+
39
+ function parseBudgetOverrides(value) {
40
+ if (!Array.isArray(value)) {
41
+ throw new Error(`E04: fin14.toml: budget.overrides must be an array of { path, budget } tables`)
42
+ }
43
+ return value.map((entry, i) => {
44
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
45
+ throw new Error(`E04: fin14.toml: budget.overrides[${i}] must be a { path, budget } table`)
46
+ }
47
+ for (const key of Object.keys(entry)) {
48
+ if (!OVERRIDE_KEYS.has(key)) {
49
+ throw new Error(`E04: fin14.toml: unknown field 'budget.overrides[${i}].${key}'`)
50
+ }
51
+ }
52
+ if (typeof entry.path !== 'string') {
53
+ throw new Error(`E04: fin14.toml: budget.overrides[${i}].path: expected string`)
54
+ }
55
+ if (entry.budget === undefined) {
56
+ throw new Error(`E04: fin14.toml: budget.overrides[${i}].budget is required`)
57
+ }
58
+ return { path: entry.path, budget: parseBudgetDefault(entry.budget, `budget.overrides[${i}].budget`) }
59
+ })
60
+ }
61
+
62
+ const VIEWPORT_PRESET_KEYS = new Set(['width', 'height', 'lazy_visible_images'])
63
+
64
+ function assertType(value, expectedType, fieldPath) {
65
+ if (typeof value !== expectedType) {
66
+ throw new Error(`fin14.toml: ${fieldPath}: expected ${expectedType}, got ${typeof value}`)
67
+ }
68
+ }
69
+
70
+ function assertInteger(value, fieldPath) {
71
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
72
+ throw new Error(`fin14.toml: ${fieldPath}: expected integer, got ${typeof value}`)
73
+ }
74
+ }
75
+
76
+ function parseBudgetDefault(value, fieldPath = 'budget.default') {
77
+ if (typeof value === 'number') {
78
+ if (!Number.isInteger(value)) {
79
+ throw new Error(`fin14.toml: ${fieldPath}: expected integer or "NNkb" string, got float`)
80
+ }
81
+ return value
82
+ }
83
+ if (typeof value === 'string') {
84
+ const match = value.match(/^(\d+(?:\.\d+)?)kb$/i)
85
+ if (!match) {
86
+ throw new Error(`fin14.toml: ${fieldPath}: invalid value "${value}", expected integer or "NNkb" string`)
87
+ }
88
+ return Math.round(parseFloat(match[1]) * BYTES_PER_KB)
89
+ }
90
+ throw new Error(`fin14.toml: ${fieldPath}: expected integer or string, got ${typeof value}`)
91
+ }
92
+
93
+ function validateViewportPreset(preset, name, userPreset) {
94
+ for (const key of Object.keys(userPreset)) {
95
+ if (!VIEWPORT_PRESET_KEYS.has(key)) {
96
+ throw new Error(`E04: fin14.toml: unknown field 'viewport.${name}.${key}'`)
97
+ }
98
+ }
99
+ const result = { ...preset }
100
+ if (userPreset.width !== undefined) {
101
+ assertInteger(userPreset.width, `viewport.${name}.width`)
102
+ if (userPreset.width <= 0) throw new Error(`fin14.toml: viewport.${name}.width: must be > 0`)
103
+ result.width = userPreset.width
104
+ }
105
+ if (userPreset.height !== undefined) {
106
+ assertInteger(userPreset.height, `viewport.${name}.height`)
107
+ if (userPreset.height <= 0) throw new Error(`fin14.toml: viewport.${name}.height: must be > 0`)
108
+ result.height = userPreset.height
109
+ }
110
+ if (userPreset.lazy_visible_images !== undefined) {
111
+ assertInteger(userPreset.lazy_visible_images, `viewport.${name}.lazy_visible_images`)
112
+ if (userPreset.lazy_visible_images < 0) throw new Error(`fin14.toml: viewport.${name}.lazy_visible_images: must be >= 0`)
113
+ result.lazy_visible_images = userPreset.lazy_visible_images
114
+ }
115
+ return result
116
+ }
117
+
118
+ function mergeConfig(user) {
119
+ const result = {
120
+ project: { ...DEFAULT_CONFIG.project, include: [...DEFAULT_CONFIG.project.include], exclude: [...DEFAULT_CONFIG.project.exclude] },
121
+ budget: { ...DEFAULT_CONFIG.budget, overrides: [...DEFAULT_CONFIG.budget.overrides], ignore: [...DEFAULT_CONFIG.budget.ignore], ignore_presets: [...DEFAULT_CONFIG.budget.ignore_presets] },
122
+ network: { ...DEFAULT_CONFIG.network },
123
+ viewport: {
124
+ default: DEFAULT_CONFIG.viewport.default,
125
+ mobile: { ...DEFAULT_CONFIG.viewport.mobile },
126
+ desktop: { ...DEFAULT_CONFIG.viewport.desktop },
127
+ },
128
+ warnings: { exclude: [...DEFAULT_CONFIG.warnings.exclude] },
129
+ }
130
+
131
+ for (const key of Object.keys(user)) {
132
+ if (!KNOWN_KEYS[key]) {
133
+ throw new Error(`E04: fin14.toml: unknown field '${key}'`)
134
+ }
135
+ }
136
+
137
+ if (user.project) {
138
+ for (const key of Object.keys(user.project)) {
139
+ if (!KNOWN_KEYS.project.has(key)) {
140
+ throw new Error(`E04: fin14.toml: unknown field 'project.${key}'`)
141
+ }
142
+ }
143
+ if (user.project.output_dir !== undefined) {
144
+ assertType(user.project.output_dir, 'string', 'project.output_dir')
145
+ result.project.output_dir = user.project.output_dir
146
+ }
147
+ if (user.project.build_command !== undefined) {
148
+ assertType(user.project.build_command, 'string', 'project.build_command')
149
+ result.project.build_command = user.project.build_command
150
+ }
151
+ if (user.project.include !== undefined) {
152
+ result.project.include = validateGlobList(user.project.include, 'project.include')
153
+ }
154
+ if (user.project.exclude !== undefined) {
155
+ result.project.exclude = validateGlobList(user.project.exclude, 'project.exclude')
156
+ }
157
+ }
158
+
159
+ if (user.budget) {
160
+ for (const key of Object.keys(user.budget)) {
161
+ if (!KNOWN_KEYS.budget.has(key)) {
162
+ throw new Error(`E04: fin14.toml: unknown field 'budget.${key}'`)
163
+ }
164
+ }
165
+ if (user.budget.default !== undefined) {
166
+ result.budget.default = parseBudgetDefault(user.budget.default)
167
+ }
168
+ if (user.budget.compression !== undefined) {
169
+ assertType(user.budget.compression, 'string', 'budget.compression')
170
+ if (!['gzip', 'brotli'].includes(user.budget.compression)) {
171
+ throw new Error(`fin14.toml: budget.compression: must be "gzip" or "brotli"`)
172
+ }
173
+ result.budget.compression = user.budget.compression
174
+ }
175
+ if (user.budget.compression_level !== undefined) {
176
+ assertInteger(user.budget.compression_level, 'budget.compression_level')
177
+ if (user.budget.compression_level < COMPRESSION_LEVEL_MIN || user.budget.compression_level > COMPRESSION_LEVEL_MAX) {
178
+ throw new Error(`fin14.toml: budget.compression_level: must be ${COMPRESSION_LEVEL_MIN}–${COMPRESSION_LEVEL_MAX}`)
179
+ }
180
+ result.budget.compression_level = user.budget.compression_level
181
+ }
182
+ if (user.budget.cut_candidate_min_bytes !== undefined) {
183
+ if (!Number.isInteger(user.budget.cut_candidate_min_bytes) || user.budget.cut_candidate_min_bytes < 0) {
184
+ throw new Error(`E04: fin14.toml: budget.cut_candidate_min_bytes must be a non-negative integer`)
185
+ }
186
+ result.budget.cut_candidate_min_bytes = user.budget.cut_candidate_min_bytes
187
+ }
188
+ if (user.budget.baseline_min_delta_bytes !== undefined) {
189
+ if (!Number.isInteger(user.budget.baseline_min_delta_bytes) || user.budget.baseline_min_delta_bytes < 0) {
190
+ throw new Error(`E04: fin14.toml: budget.baseline_min_delta_bytes must be a non-negative integer`)
191
+ }
192
+ result.budget.baseline_min_delta_bytes = user.budget.baseline_min_delta_bytes
193
+ }
194
+ if (user.budget.overrides !== undefined) {
195
+ result.budget.overrides = parseBudgetOverrides(user.budget.overrides)
196
+ }
197
+ if (user.budget.ignore !== undefined) {
198
+ result.budget.ignore = validateGlobList(user.budget.ignore, 'budget.ignore')
199
+ }
200
+ if (user.budget.ignore_presets !== undefined) {
201
+ const presets = validateGlobList(user.budget.ignore_presets, 'budget.ignore_presets')
202
+ for (const name of presets) {
203
+ if (!IGNORE_PRESETS[name]) {
204
+ throw new Error(`E04: fin14.toml: unknown budget.ignore_presets value '${name}' (known: ${Object.keys(IGNORE_PRESETS).join(', ')})`)
205
+ }
206
+ }
207
+ result.budget.ignore_presets = presets
208
+ }
209
+ }
210
+
211
+ if (user.network) {
212
+ for (const key of Object.keys(user.network)) {
213
+ if (!KNOWN_KEYS.network.has(key)) {
214
+ throw new Error(`E04: fin14.toml: unknown field 'network.${key}'`)
215
+ }
216
+ }
217
+ if (user.network.timeout_ms !== undefined) {
218
+ assertInteger(user.network.timeout_ms, 'network.timeout_ms')
219
+ if (user.network.timeout_ms <= 0) throw new Error(`fin14.toml: network.timeout_ms: must be > 0`)
220
+ result.network.timeout_ms = user.network.timeout_ms
221
+ }
222
+ if (user.network.max_redirects !== undefined) {
223
+ assertInteger(user.network.max_redirects, 'network.max_redirects')
224
+ if (user.network.max_redirects < 0) throw new Error(`fin14.toml: network.max_redirects: must be >= 0`)
225
+ result.network.max_redirects = user.network.max_redirects
226
+ }
227
+ }
228
+
229
+ if (user.viewport) {
230
+ for (const key of Object.keys(user.viewport)) {
231
+ if (!KNOWN_KEYS.viewport.has(key)) {
232
+ throw new Error(`E04: fin14.toml: unknown field 'viewport.${key}'`)
233
+ }
234
+ }
235
+ if (user.viewport.default !== undefined) {
236
+ assertType(user.viewport.default, 'string', 'viewport.default')
237
+ if (!['mobile', 'desktop'].includes(user.viewport.default)) {
238
+ throw new Error(`fin14.toml: viewport.default: must be "mobile" or "desktop"`)
239
+ }
240
+ result.viewport.default = user.viewport.default
241
+ }
242
+ if (user.viewport.mobile !== undefined) {
243
+ if (typeof user.viewport.mobile !== 'object' || Array.isArray(user.viewport.mobile)) {
244
+ throw new Error(`fin14.toml: viewport.mobile: expected object, got ${typeof user.viewport.mobile}`)
245
+ }
246
+ result.viewport.mobile = validateViewportPreset(result.viewport.mobile, 'mobile', user.viewport.mobile)
247
+ }
248
+ if (user.viewport.desktop !== undefined) {
249
+ if (typeof user.viewport.desktop !== 'object' || Array.isArray(user.viewport.desktop)) {
250
+ throw new Error(`fin14.toml: viewport.desktop: expected object, got ${typeof user.viewport.desktop}`)
251
+ }
252
+ result.viewport.desktop = validateViewportPreset(result.viewport.desktop, 'desktop', user.viewport.desktop)
253
+ }
254
+ }
255
+
256
+ if (user.warnings) {
257
+ for (const key of Object.keys(user.warnings)) {
258
+ if (!KNOWN_KEYS.warnings.has(key)) {
259
+ throw new Error(`E04: fin14.toml: unknown field 'warnings.${key}'`)
260
+ }
261
+ }
262
+ if (user.warnings.exclude !== undefined) {
263
+ if (!Array.isArray(user.warnings.exclude) || !user.warnings.exclude.every(v => typeof v === 'string')) {
264
+ throw new Error(`E04: fin14.toml: warnings.exclude must be an array of strings`)
265
+ }
266
+ result.warnings.exclude = user.warnings.exclude
267
+ }
268
+ }
269
+
270
+ return result
271
+ }
272
+
273
+ export async function loadConfig(options = {}) {
274
+ let raw = null
275
+
276
+ if (options.config) {
277
+ let content
278
+ try {
279
+ content = await readFile(options.config, 'utf8')
280
+ } catch (err) {
281
+ throw new Error(`E03: fin14.toml: ${err.message}`)
282
+ }
283
+ try {
284
+ raw = parse(content)
285
+ } catch (err) {
286
+ throw new Error(`E03: fin14.toml: ${err.message}`)
287
+ }
288
+ } else {
289
+ const defaultPath = `${process.cwd()}/fin14.toml`
290
+ try {
291
+ const content = await readFile(defaultPath, 'utf8')
292
+ try {
293
+ raw = parse(content)
294
+ } catch (err) {
295
+ throw new Error(`E03: fin14.toml: ${err.message}`)
296
+ }
297
+ } catch (err) {
298
+ if (err.message.startsWith('E03:')) throw err
299
+ }
300
+ }
301
+
302
+ const config = mergeConfig(raw ?? {})
303
+
304
+ if (options.outputDir !== undefined) {
305
+ config.project.output_dir = options.outputDir
306
+ }
307
+
308
+ if (config.project.output_dir === null) {
309
+ config.project.output_dir = await detectOutputDir(process.cwd())
310
+ }
311
+
312
+ return config
313
+ }
@@ -0,0 +1,42 @@
1
+ export const BYTES_PER_KB = 1000
2
+ export const MILLISECONDS_PER_SECOND = 1000
3
+
4
+ export const DEFAULT_CONFIG = {
5
+ project: { output_dir: null, build_command: null, include: [], exclude: [] },
6
+ budget: {
7
+ default: 14000,
8
+ compression: 'gzip',
9
+ compression_level: 6,
10
+ cut_candidate_min_bytes: 2800,
11
+ baseline_min_delta_bytes: 100,
12
+ overrides: [],
13
+ ignore: [],
14
+ ignore_presets: [],
15
+ },
16
+ network: { timeout_ms: 5000, max_redirects: 5 },
17
+ viewport: {
18
+ default: 'mobile',
19
+ mobile: { width: 390, height: 844, lazy_visible_images: 1 },
20
+ desktop: { width: 1366, height: 768, lazy_visible_images: 1 },
21
+ },
22
+ warnings: { exclude: [] },
23
+ }
24
+
25
+ export const GZIP_MIN_LEVEL = 1
26
+ export const GZIP_MAX_LEVEL = 9
27
+ export const COMPRESSION_LEVEL_MIN = 0
28
+ export const COMPRESSION_LEVEL_MAX = 11
29
+
30
+ export const STRICT_IPV6_LIMIT_BYTES = 12200
31
+
32
+ export const IGNORE_PRESETS = {
33
+ nextjs: { label: 'Next.js client JS', globs: ['_next/static/chunks/**.js'] },
34
+ }
35
+
36
+ export const SITE_PAGE_BATCH_SIZE = 50
37
+ export const SITE_EMFILE_RETRY_BATCH_SIZE = 10
38
+ export const SUMMARY_TOP_ASSET_LIMIT = 10
39
+
40
+ export const EXTERNAL_CACHE_TTL_SECONDS = 3600
41
+ export const EXTERNAL_DOMAIN_CONCURRENCY = 10
42
+ export const EXTERNAL_DOMAIN_WAIT_MS = 50
package/src/crawler.js ADDED
@@ -0,0 +1,34 @@
1
+ import { readdir, stat, lstat } from 'node:fs/promises'
2
+ import { join, relative } from 'node:path'
3
+
4
+ export async function crawl(outputDir) {
5
+ const htmlFiles = []
6
+
7
+ async function walk(dir) {
8
+ const entries = await readdir(dir, { withFileTypes: true })
9
+ for (const entry of entries) {
10
+ const fullPath = join(dir, entry.name)
11
+
12
+ if (entry.isSymbolicLink()) {
13
+ let linkTarget
14
+ try {
15
+ linkTarget = await stat(fullPath)
16
+ } catch {
17
+ continue
18
+ }
19
+ if (linkTarget.isFile() && entry.name.endsWith('.html')) {
20
+ htmlFiles.push(relative(outputDir, fullPath))
21
+ }
22
+ } else if (entry.isDirectory()) {
23
+ await walk(fullPath)
24
+ } else if (entry.isFile() && entry.name.endsWith('.html')) {
25
+ htmlFiles.push(relative(outputDir, fullPath))
26
+ }
27
+ }
28
+ }
29
+
30
+ await walk(outputDir)
31
+ return htmlFiles
32
+ .map(p => p.replace(/\\/g, '/'))
33
+ .sort()
34
+ }
package/src/glob.js ADDED
@@ -0,0 +1,51 @@
1
+ function globToRegExp(pattern) {
2
+ const normalized = pattern.replace(/\\/g, '/')
3
+ let re = ''
4
+ for (let i = 0; i < normalized.length; i++) {
5
+ const char = normalized[i]
6
+ if (char === '*') {
7
+ if (normalized[i + 1] === '*') {
8
+ if (normalized[i + 2] === '/') {
9
+ re += '(?:.*/)?'
10
+ i += 2
11
+ } else {
12
+ re += '.*'
13
+ i++
14
+ }
15
+ } else {
16
+ re += '[^/]*'
17
+ }
18
+ } else if (char === '?') {
19
+ re += '[^/]'
20
+ } else if ('.+^${}()|[]\\'.includes(char)) {
21
+ re += '\\' + char
22
+ } else {
23
+ re += char
24
+ }
25
+ }
26
+ return new RegExp('^' + re + '$')
27
+ }
28
+
29
+ export function matchGlob(path, pattern) {
30
+ return globToRegExp(pattern).test(path.replace(/\\/g, '/'))
31
+ }
32
+
33
+ export function matchesAny(path, patterns) {
34
+ return patterns.some(pattern => matchGlob(path, pattern))
35
+ }
36
+
37
+ export function filterPages(relPaths, config) {
38
+ const include = config.project?.include ?? []
39
+ const exclude = config.project?.exclude ?? []
40
+ return relPaths.filter(path => {
41
+ if (include.length > 0 && !matchesAny(path, include)) return false
42
+ if (matchesAny(path, exclude)) return false
43
+ return true
44
+ })
45
+ }
46
+
47
+ export function resolveBudget(relFile, config) {
48
+ const overrides = config.budget?.overrides ?? []
49
+ const match = overrides.find(override => matchGlob(relFile, override.path))
50
+ return match ? match.budget : config.budget.default
51
+ }
package/src/ignore.js ADDED
@@ -0,0 +1,55 @@
1
+ import { IGNORE_PRESETS } from './constants.js'
2
+ import { matchesAny } from './glob.js'
3
+ import { computeCutCandidates, deriveStatusWarnings } from './budget/validator.js'
4
+ import { summarize } from './index.js'
5
+
6
+ export function resolveIgnoreGlobs(config) {
7
+ const explicit = config.budget?.ignore ?? []
8
+ const presets = config.budget?.ignore_presets ?? []
9
+ const fromPresets = presets.flatMap(name => IGNORE_PRESETS[name]?.globs ?? [])
10
+ return [...explicit, ...fromPresets]
11
+ }
12
+
13
+ export function applyIgnore(resources, ignoreGlobs) {
14
+ const sumBytes = list => list.reduce((s, r) => s + r.bytes, 0)
15
+ if (!ignoreGlobs || ignoreGlobs.length === 0) {
16
+ return { counted: resources, ignored: [], countedBytes: sumBytes(resources), ignoredBytes: 0 }
17
+ }
18
+ const counted = []
19
+ const ignored = []
20
+ for (const r of resources) {
21
+ if (matchesAny(r.displayPath, ignoreGlobs)) ignored.push(r)
22
+ else counted.push(r)
23
+ }
24
+ return { counted, ignored, countedBytes: sumBytes(counted), ignoredBytes: sumBytes(ignored) }
25
+ }
26
+
27
+ export function applyIgnoreToPage(page, ignoreGlobs, config) {
28
+ const { counted, ignored, countedBytes, ignoredBytes } = applyIgnore(page.resources, ignoreGlobs)
29
+ const limitBytes = page.limitBytes
30
+ const hasOverride = limitBytes !== config.budget.default
31
+ const overBudget = countedBytes > limitBytes
32
+
33
+ const baseWarnings = page.warnings.filter(w => w.code !== 'E13' && w.code !== 'E14')
34
+ const warnings = [...baseWarnings, ...deriveStatusWarnings(counted, countedBytes, limitBytes, hasOverride, page.file)]
35
+ const status = overBudget ? 'fail' : warnings.length > 0 ? 'warn' : 'pass'
36
+
37
+ return {
38
+ ...page,
39
+ totalBytes: countedBytes,
40
+ overBudget,
41
+ status,
42
+ resources: counted,
43
+ ignoredResources: ignored,
44
+ ignoredBytes,
45
+ warnings,
46
+ cutCandidates: computeCutCandidates(counted, countedBytes, limitBytes, overBudget),
47
+ }
48
+ }
49
+
50
+ export function applyIgnoreToSummary(summary, ignoreGlobs, config) {
51
+ const results = summary.results.map(page => applyIgnoreToPage(page, ignoreGlobs, config))
52
+ const recomputed = summarize(results, summary.outputDir, summary.siteWarnings)
53
+ recomputed.generatedAt = summary.generatedAt
54
+ return recomputed
55
+ }
package/src/index.js ADDED
@@ -0,0 +1,94 @@
1
+ export { loadConfig } from './config.js'
2
+ export { gzipLevelClampNotice } from './compress.js'
3
+ export { matchGlob, filterPages, resolveBudget } from './glob.js'
4
+ export { resolveIgnoreGlobs, applyIgnore, applyIgnoreToPage, applyIgnoreToSummary } from './ignore.js'
5
+ export * from './constants.js'
6
+ import { collect } from './budget/collector.js'
7
+ import { filterPages } from './glob.js'
8
+ import { measure } from './budget/measurer.js'
9
+ import { validate } from './budget/validator.js'
10
+ import { crawl } from './crawler.js'
11
+ import { relative, resolve } from 'node:path'
12
+ import {
13
+ SITE_EMFILE_RETRY_BATCH_SIZE,
14
+ SITE_PAGE_BATCH_SIZE,
15
+ SUMMARY_TOP_ASSET_LIMIT,
16
+ } from './constants.js'
17
+
18
+ export async function measurePage(file, config) {
19
+ const { refs, skipped, warnings: collectWarnings } = await collect(file, config.project.output_dir, config)
20
+ const { results, warnings: measureWarnings, unknownBudget } = await measure(refs, config)
21
+ const allWarnings = [...collectWarnings, ...measureWarnings]
22
+ const relFile = relative(config.project.output_dir, file)
23
+ const pageResult = validate(results, skipped, allWarnings, config, relFile)
24
+ if (unknownBudget) pageResult.unknownBudget = true
25
+ return pageResult
26
+ }
27
+
28
+ export async function measureSite(outputDir, config, options = {}) {
29
+ const configWithDir = { ...config, project: { ...config.project, output_dir: outputDir } }
30
+ const relPaths = filterPages(await crawl(outputDir), configWithDir)
31
+ const absPaths = relPaths.map(p => resolve(outputDir, p))
32
+ const total = absPaths.length
33
+
34
+ options.onStart?.({ total, files: relPaths })
35
+
36
+ let batchSize = SITE_PAGE_BATCH_SIZE
37
+ let i = 0
38
+ let done = 0
39
+ const results = []
40
+ const siteWarnings = []
41
+
42
+ while (i < absPaths.length) {
43
+ const batch = absPaths.slice(i, i + batchSize)
44
+ try {
45
+ const batchResults = await Promise.all(batch.map(async f => {
46
+ const result = await measurePage(f, configWithDir)
47
+ done++
48
+ options.onPageResult?.(result, { done, total })
49
+ return result
50
+ }))
51
+ results.push(...batchResults)
52
+ i += batch.length
53
+ } catch (err) {
54
+ if (err.code === 'EMFILE' && batchSize > SITE_EMFILE_RETRY_BATCH_SIZE) {
55
+ batchSize = SITE_EMFILE_RETRY_BATCH_SIZE
56
+ siteWarnings.push({ code: 'E15', message: `too many open files — retrying with batch size ${SITE_EMFILE_RETRY_BATCH_SIZE}`, strictFailure: false })
57
+ } else {
58
+ throw err
59
+ }
60
+ }
61
+ }
62
+
63
+ return summarize(results, outputDir, siteWarnings)
64
+ }
65
+
66
+ export function summarize(results, outputDir, siteWarnings = []) {
67
+ const pass = results.filter(r => r.status === 'pass').length
68
+ const warn = results.filter(r => r.status === 'warn').length
69
+ const fail = results.filter(r => r.status === 'fail').length
70
+ const totalBytes = results.reduce((s, r) => s + r.totalBytes, 0)
71
+
72
+ const seen = new Map()
73
+ for (const page of results) {
74
+ for (const res of page.resources) {
75
+ if (!seen.has(res.key) || seen.get(res.key).bytes < res.bytes) {
76
+ seen.set(res.key, res)
77
+ }
78
+ }
79
+ }
80
+ const topAssets = [...seen.values()].sort((a, b) => b.bytes - a.bytes).slice(0, SUMMARY_TOP_ASSET_LIMIT)
81
+
82
+ return {
83
+ outputDir,
84
+ generatedAt: new Date().toISOString(),
85
+ pages: results.length,
86
+ pass,
87
+ warn,
88
+ fail,
89
+ totalBytes,
90
+ topAssets,
91
+ siteWarnings,
92
+ results,
93
+ }
94
+ }