@kosdev-code/kos-asset-manager 0.0.1-next.20

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,404 @@
1
+ /**
2
+ * Derives a kondra.ui.assets declaration from a project's media directory.
3
+ */
4
+ import { createHash } from 'node:crypto';
5
+ import {
6
+ existsSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+
13
+ const SOURCE_DIR = ['src', 'assets', 'media'];
14
+ const ASSETS_ROOT = '/assets/media';
15
+ const TYPES = {
16
+ '.avif': 'image/avif',
17
+ '.gif': 'image/gif',
18
+ '.jpeg': 'image/jpeg',
19
+ '.jpg': 'image/jpeg',
20
+ '.mp3': 'audio/mpeg',
21
+ '.mp4': 'video/mp4',
22
+ '.png': 'image/png',
23
+ '.svg': 'image/svg+xml',
24
+ '.wav': 'audio/wav',
25
+ '.webm': 'video/webm',
26
+ '.webp': 'image/webp',
27
+ '.woff2': 'font/woff2',
28
+ };
29
+
30
+ /**
31
+ * Patterns select assets by key so a set can be declared once instead of
32
+ * naming every file. `*` matches within a path segment, `**` across
33
+ * segments, and a pattern with no wildcard is a plain key.
34
+ */
35
+ const matcher = (pattern) => {
36
+ const source = pattern
37
+ .split('**')
38
+ .map((part) =>
39
+ part
40
+ .split('*')
41
+ .map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
42
+ .join('[^/]*')
43
+ )
44
+ .join('.*');
45
+ return new RegExp(`^${source}$`);
46
+ };
47
+
48
+ const selectKeys = (keys, patterns) => {
49
+ const selected = [];
50
+ for (const pattern of patterns) {
51
+ const test = matcher(pattern);
52
+ const hits = keys.filter((key) => test.test(key));
53
+ if (hits.length === 0) {
54
+ console.warn(`kos-assets: nothing matches ${pattern}`);
55
+ }
56
+ for (const hit of hits) {
57
+ if (!selected.includes(hit)) selected.push(hit);
58
+ }
59
+ }
60
+ return selected;
61
+ };
62
+
63
+ /**
64
+ * Dimensions of an mp4, mov or m4v, read from its track header. The header
65
+ * ends with the display width and height as 16.16 fixed point, and audio
66
+ * tracks carry zeroes, so the first non-zero pair is the video.
67
+ */
68
+ const measureIsoMedia = (buffer) => {
69
+ let at = 0;
70
+ while (at + 8 <= buffer.length) {
71
+ const found = buffer.indexOf('tkhd', at, 'ascii');
72
+ if (found < 4) return undefined;
73
+
74
+ const size = buffer.readUInt32BE(found - 4);
75
+ const end = found - 4 + size;
76
+ if (size < 16 || end > buffer.length) return undefined;
77
+
78
+ const width = buffer.readUInt32BE(end - 8) / 65536;
79
+ const height = buffer.readUInt32BE(end - 4) / 65536;
80
+ if (width > 0 && height > 0) {
81
+ return { width: Math.round(width), height: Math.round(height) };
82
+ }
83
+ at = found + 4;
84
+ }
85
+ return undefined;
86
+ };
87
+
88
+ /**
89
+ * Pixel dimensions of an image, read from its header. An unrecognised
90
+ * format is left unmeasured.
91
+ */
92
+ const measure = (buffer, ext) => {
93
+ try {
94
+ if (ext === '.png' && buffer.length > 24) {
95
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
96
+ }
97
+ if (ext === '.gif' && buffer.length > 10) {
98
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
99
+ }
100
+ if (ext === '.jpg' || ext === '.jpeg') {
101
+ let at = 2;
102
+ while (at + 9 < buffer.length) {
103
+ if (buffer[at] !== 0xff) break;
104
+ const marker = buffer[at + 1];
105
+ const length = buffer.readUInt16BE(at + 2);
106
+ const isSof =
107
+ marker >= 0xc0 && marker <= 0xcf &&
108
+ marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
109
+ if (isSof) {
110
+ return {
111
+ height: buffer.readUInt16BE(at + 5),
112
+ width: buffer.readUInt16BE(at + 7),
113
+ };
114
+ }
115
+ at += 2 + length;
116
+ }
117
+ return undefined;
118
+ }
119
+ if (ext === '.webp' && buffer.length > 30) {
120
+ const chunk = buffer.toString('ascii', 12, 16);
121
+ if (chunk === 'VP8X') {
122
+ return {
123
+ width: buffer.readUIntLE(24, 3) + 1,
124
+ height: buffer.readUIntLE(27, 3) + 1,
125
+ };
126
+ }
127
+ if (chunk === 'VP8 ') {
128
+ return {
129
+ width: buffer.readUInt16LE(26) & 0x3fff,
130
+ height: buffer.readUInt16LE(28) & 0x3fff,
131
+ };
132
+ }
133
+ return undefined;
134
+ }
135
+ if (ext === '.mp4' || ext === '.mov' || ext === '.m4v') {
136
+ return measureIsoMedia(buffer);
137
+ }
138
+ if (ext === '.svg') {
139
+ const text = buffer.toString('utf8', 0, 2048);
140
+ const viewBox = /viewBox\s*=\s*["']\s*[\d.-]+[ ,]+[\d.-]+[ ,]+([\d.]+)[ ,]+([\d.]+)/.exec(text);
141
+ if (viewBox) {
142
+ return { width: Math.round(+viewBox[1]), height: Math.round(+viewBox[2]) };
143
+ }
144
+ const width = /\bwidth\s*=\s*["']([\d.]+)/.exec(text);
145
+ const height = /\bheight\s*=\s*["']([\d.]+)/.exec(text);
146
+ if (width && height) {
147
+ return { width: Math.round(+width[1]), height: Math.round(+height[1]) };
148
+ }
149
+ }
150
+ } catch {
151
+ // an unreadable header just means the asset goes unmeasured
152
+ }
153
+ return undefined;
154
+ };
155
+
156
+ const MARKER_FILE = '.kosassets';
157
+
158
+ /**
159
+ * Key bindings declared beside the files they name. Sources are relative to
160
+ * the marker's folder, keys are absolute, so moving the folder changes no key.
161
+ *
162
+ * # media/brand/.kosassets
163
+ * brand/logo-primary = logos/primary.svg
164
+ * brand/{name} = *.svg
165
+ */
166
+ const parseMarker = (contents) => {
167
+ const bindings = {};
168
+ for (const line of contents.split(/\r?\n/)) {
169
+ const text = line.replace(/#.*$/, '').trim();
170
+ if (!text) continue;
171
+
172
+ const at = text.indexOf('=');
173
+ if (at === -1) {
174
+ console.warn(`kos-assets: ignoring marker line without '=': ${text}`);
175
+ continue;
176
+ }
177
+ bindings[text.slice(0, at).trim()] = text.slice(at + 1).trim();
178
+ }
179
+ return bindings;
180
+ };
181
+
182
+ /** Every marker below the media directory, deepest first. */
183
+ const readMarkers = (dir, prefix = '') => {
184
+ const found = [];
185
+ const marker = join(dir, MARKER_FILE);
186
+ if (existsSync(marker)) {
187
+ found.push({ dir: prefix, bindings: parseMarker(readFileSync(marker, 'utf8')) });
188
+ }
189
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
190
+ if (item.isDirectory()) {
191
+ found.push(
192
+ ...readMarkers(join(dir, item.name), prefix ? `${prefix}/${item.name}` : item.name)
193
+ );
194
+ }
195
+ }
196
+ return found.sort((a, b) => b.dir.split('/').length - a.dir.split('/').length);
197
+ };
198
+
199
+ /**
200
+ * Key bindings pin a key to a file so the key survives the file moving.
201
+ * A binding's source is a path relative to the media directory, and may
202
+ * capture with {name} or span folders with **:
203
+ *
204
+ * "brand/logo-primary": "logos/primary.svg"
205
+ * "beverages/{name}": "drinks/{name}.svg"
206
+ * "video/globe": "**\/globe.mp4"
207
+ */
208
+ const bindingMatcher = (pattern) => {
209
+ const names = [];
210
+ let source = '';
211
+ const parts = pattern.split(/(\{[a-zA-Z][\w-]*\}|\*\*|\*)/);
212
+
213
+ for (const part of parts) {
214
+ if (part === '**') {
215
+ source += '.*';
216
+ } else if (part === '*') {
217
+ source += '[^/]*';
218
+ } else if (part.startsWith('{')) {
219
+ const name = part.slice(1, -1);
220
+ names.push(name);
221
+ source += '([^/]+)';
222
+ } else {
223
+ source += part.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
224
+ }
225
+ }
226
+ return { regex: new RegExp(`^${source}$`), names };
227
+ };
228
+
229
+ /**
230
+ * The key a file is bound to, or undefined when nothing claims it.
231
+ */
232
+ const boundKey = (rel, bindings) => {
233
+ for (const [keyTemplate, pattern] of Object.entries(bindings)) {
234
+ const { regex, names } = bindingMatcher(pattern);
235
+ const match = regex.exec(rel);
236
+ if (!match) continue;
237
+
238
+ return names.reduce(
239
+ (key, name, index) => key.replaceAll(`{${name}}`, match[index + 1]),
240
+ keyTemplate
241
+ );
242
+ }
243
+ return undefined;
244
+ };
245
+
246
+ const fail = (message) => {
247
+ console.error(`kos-assets: ${message}`);
248
+ process.exit(1);
249
+ };
250
+
251
+ const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'));
252
+
253
+ const writeJson = (path, value) =>
254
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
255
+
256
+ /**
257
+ * Every file below the media directory, with its path relative to it.
258
+ */
259
+ const walk = (dir, prefix = '') =>
260
+ readdirSync(dir, { withFileTypes: true }).flatMap((item) => {
261
+ if (item.name.startsWith('.')) return [];
262
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
263
+ const child = join(dir, item.name);
264
+ return item.isDirectory() ? walk(child, rel) : [{ rel, file: child }];
265
+ });
266
+
267
+ /**
268
+ * Build the assets block. Entries are derived from disk on every build;
269
+ * groups and preload flags are authored in .kos.json and carried through.
270
+ */
271
+ const buildAssets = (projectRoot) => {
272
+ const mediaDir = resolve(projectRoot, ...SOURCE_DIR);
273
+ if (!existsSync(mediaDir)) {
274
+ return undefined;
275
+ }
276
+
277
+ const dotKos = resolve(projectRoot, '.kos.json');
278
+ const declared = existsSync(dotKos)
279
+ ? readJson(dotKos)?.kondra?.ui?.assets ?? {}
280
+ : {};
281
+
282
+ const bindings = declared.keys ?? {};
283
+ const markers = readMarkers(mediaDir);
284
+ const claimed = new Set();
285
+
286
+ // .kos.json wins, then the nearest marker, then the file's own path
287
+ const keyFor = (rel) => {
288
+ const central = boundKey(rel, bindings);
289
+ if (central) return central;
290
+
291
+ for (const marker of markers) {
292
+ if (marker.dir && !rel.startsWith(`${marker.dir}/`)) continue;
293
+ const relative = marker.dir ? rel.slice(marker.dir.length + 1) : rel;
294
+ const key = boundKey(relative, marker.bindings);
295
+ if (key) return key;
296
+ }
297
+ return undefined;
298
+ };
299
+
300
+ const entries = {};
301
+ for (const { rel, file } of walk(mediaDir)) {
302
+ const ext = rel.slice(rel.lastIndexOf('.')).toLowerCase();
303
+ const bound = keyFor(rel);
304
+ if (bound) claimed.add(bound);
305
+ const key = bound ?? rel.slice(0, rel.length - ext.length);
306
+ const contents = readFileSync(file);
307
+ entries[key] = {
308
+ path: rel,
309
+ type: TYPES[ext] ?? 'application/octet-stream',
310
+ size: contents.length,
311
+ hash: createHash('sha256').update(contents).digest('hex').slice(0, 16),
312
+ ...(measure(contents, ext) ?? {}),
313
+ };
314
+ if (declared.entries?.[key]?.preload) {
315
+ entries[key].preload = true;
316
+ }
317
+ const aliases = declared.entries?.[key]?.aliases;
318
+ if (aliases?.length) {
319
+ entries[key].aliases = aliases;
320
+ }
321
+ }
322
+
323
+ if (Object.keys(entries).length === 0) {
324
+ return undefined;
325
+ }
326
+
327
+ // a video shows the image that shares its key, so dropping a still beside
328
+ // a clip is all it takes to give it a thumbnail
329
+ for (const [key, entry] of Object.entries(entries)) {
330
+ if (!entry.type.startsWith('video/')) {
331
+ continue;
332
+ }
333
+ const declaredPoster = declared.entries?.[key]?.poster;
334
+ const paired =
335
+ declaredPoster ??
336
+ [`${key}-poster`, `${key}-still`].find((candidate) =>
337
+ entries[candidate]?.type?.startsWith('image/')
338
+ );
339
+ if (paired && entries[paired]) {
340
+ entry.poster = paired;
341
+ if (!entry.width && entries[paired].width) {
342
+ entry.width = entries[paired].width;
343
+ entry.height = entries[paired].height;
344
+ }
345
+ } else if (declaredPoster) {
346
+ console.warn(
347
+ `kos-assets: poster ${declaredPoster} for ${key} is not an asset`
348
+ );
349
+ }
350
+ }
351
+
352
+ // expand tag patterns onto the entries they select
353
+ const keys = Object.keys(entries);
354
+ for (const [tag, patterns] of Object.entries(declared.tags ?? {})) {
355
+ for (const key of selectKeys(keys, patterns)) {
356
+ entries[key].tags = [...(entries[key].tags ?? []), tag];
357
+ }
358
+ }
359
+
360
+ // expand group patterns into the keys they select
361
+ const groups = {};
362
+ for (const [group, patterns] of Object.entries(declared.groups ?? {})) {
363
+ groups[group] = selectKeys(keys, patterns);
364
+ }
365
+
366
+ for (const [key, pattern] of Object.entries(bindings)) {
367
+ if (!claimed.has(key) && !key.includes('{')) {
368
+ console.warn(`kos-assets: no file matches the binding ${key} = ${pattern}`);
369
+ }
370
+ }
371
+
372
+ const unknown = Object.keys(declared.entries ?? {}).filter(
373
+ (key) => !entries[key]
374
+ );
375
+ if (unknown.length > 0) {
376
+ console.warn(
377
+ `kos-assets: declared in .kos.json but not on disk: ${unknown.join(', ')}`
378
+ );
379
+ }
380
+
381
+ return {
382
+ root: ASSETS_ROOT,
383
+ entries,
384
+ ...(Object.keys(groups).length > 0 ? { groups } : {}),
385
+ };
386
+ };
387
+
388
+ export {
389
+ MARKER_FILE,
390
+ SOURCE_DIR,
391
+ ASSETS_ROOT,
392
+ TYPES,
393
+ parseMarker,
394
+ readMarkers,
395
+ bindingMatcher,
396
+ boundKey,
397
+ selectKeys,
398
+ measure,
399
+ measureIsoMedia,
400
+ walk,
401
+ readJson,
402
+ writeJson,
403
+ buildAssets,
404
+ };
package/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from './models/asset-entry';
2
+ export * from './models/asset-context';
3
+ export * from './models/asset-manager';
4
+ export { registerAssetModels } from './registration';
5
+ export { contextNames } from './runtime/assets';
6
+ export { KosAssetProvider, KosAssetContext, useAssetManager, useKosAssetContext, } from './runtime/asset-provider';
7
+ export { resolveAsset, wrapAsset, hasAsset, listAssets } from './runtime/resolve';
8
+ export { preloadAssets, preloadDeclared, releaseAssets, } from './runtime/preload';
9
+ export type { AssetSelector, PreloadOptions, PreloadProgress, ResolvedAsset, } from './runtime/types';
10
+ //# sourceMappingURL=index.d.ts.map