@kosdev-code/kos-asset-manager 0.0.1-next.29 → 0.0.1-next.30

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/README.md CHANGED
@@ -119,6 +119,71 @@ keys when the declaration is generated:
119
119
 
120
120
  A pattern that matches nothing is reported during the build.
121
121
 
122
+ ## Media somewhere else
123
+
124
+ `src/assets/media` is the ui default. A content project — a brandset, a
125
+ descriptor pack — usually keeps its media elsewhere and ships it at a
126
+ different path inside the kab, so both are settable:
127
+
128
+ ```json
129
+ {
130
+ "kondra": {
131
+ "ui": {
132
+ "assets": {
133
+ "mediaSrc": "assets/logos",
134
+ "mediaRoot": "/logos",
135
+ "groups": { "boot": ["coke/**"] }
136
+ }
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ `mediaSrc` is where the files are **on disk**, relative to the project root.
143
+ `mediaRoot` is where they land **inside the kab**, which is what urls are
144
+ built from. They are two different things — a project using vite's
145
+ `publicDir` drops the directory name on the way out, so `assets/logos` on
146
+ disk is `/logos` in the kab — and neither can be derived from the other.
147
+ Leave both out and you get `src/assets/media` and `/assets/media`.
148
+
149
+ Keys are still relative to `mediaSrc`, so `assets/logos/coke/pour.svg` is the
150
+ key `coke/pour`, and bindings, groups and tags work exactly as they do
151
+ anywhere else.
152
+
153
+ The build checks its work: every declared file has to be in the build output
154
+ under `mediaRoot`, and if none of them are the build fails naming the setting
155
+ rather than leaving a kab whose every image 404s on the device.
156
+
157
+ ```
158
+ kos-assets: 214 declared assets are not in the build output under
159
+ /assets/media — check mediaRoot in .kos.json, and that the build copies
160
+ the media into dist/content/ui-brandset/assets/media
161
+ ```
162
+
163
+ You don't have to work either of them out by hand. `kos-assets init` looks
164
+ for the media on disk, and reads where the build puts it out of the build
165
+ output — exact, where guessing from the source path would be wrong for any
166
+ project that rewrites paths on the way out:
167
+
168
+ ```sh
169
+ npx nx run ui-brandset:build # so there is an output to read
170
+ npx kos-assets init content/ui-brandset
171
+ ```
172
+
173
+ ```
174
+ kos-assets: found media in assets/logos
175
+ kos-assets: it is served from /logos in the kab
176
+ kos-assets: recorded the media location in content/ui-brandset/.kos.json
177
+ kos-assets: added the assets target to content/ui-brandset/project.json
178
+ ```
179
+
180
+ It writes only what it can establish and never touches a value already
181
+ there, so a project it gets wrong is corrected by setting it and running
182
+ again. Without a build output it records `mediaSrc` and says it could not
183
+ tell what the root is, rather than guessing one. The target it writes then
184
+ scans the right folder and follows the project's own `descriptor` target
185
+ wherever that writes.
186
+
122
187
  ## Installing
123
188
 
124
189
  ```sh
@@ -10,11 +10,13 @@
10
10
  * adds the target that runs it to the project.
11
11
  */
12
12
  import { existsSync } from 'node:fs';
13
- import { join, resolve } from 'node:path';
13
+ import { dirname, join, resolve } from 'node:path';
14
14
 
15
15
  import {
16
- SOURCE_DIR,
17
16
  buildAssets,
17
+ detectMediaRoot,
18
+ detectMediaSrc,
19
+ mediaSource,
18
20
  readJson,
19
21
  writeJson,
20
22
  } from './lib/declare-assets.mjs';
@@ -24,10 +26,129 @@ const fail = (message) => {
24
26
  process.exit(1);
25
27
  };
26
28
 
27
- const build = (projectRoot, out) => {
28
- const descriptorPath = resolve(
29
- out ?? join('dist', projectRoot, 'descriptor.json')
29
+ /**
30
+ * The declared paths are only useful if the files are actually in the kab at
31
+ * the root the declaration names. The build output is what gets zipped, so
32
+ * it can be checked here -- which turns a wrong `mediaRoot` into a build
33
+ * error rather than an image that 404s on the device.
34
+ */
35
+ const verifyContents = (assets, outputDir) => {
36
+ const missing = Object.values(assets.entries).filter(
37
+ (entry) => !existsSync(join(outputDir, assets.root, entry.path))
30
38
  );
39
+ if (missing.length === 0) {
40
+ return;
41
+ }
42
+
43
+ const total = Object.keys(assets.entries).length;
44
+ const root = assets.root || '/';
45
+ if (missing.length === total) {
46
+ fail(
47
+ `${total} declared assets are not in the build output under ${root} — ` +
48
+ `check mediaRoot in .kos.json, and that the build copies the media ` +
49
+ `into ${join(outputDir, assets.root)}`
50
+ );
51
+ }
52
+ console.warn(
53
+ `kos-assets: ${missing.length} of ${total} declared assets are not in ` +
54
+ `the build output: ${missing
55
+ .slice(0, 5)
56
+ .map((entry) => entry.path)
57
+ .join(', ')}${missing.length > 5 ? ', …' : ''}`
58
+ );
59
+ };
60
+
61
+ /** Where the descriptor lands unless the project says otherwise. */
62
+ const defaultDescriptor = (projectRoot) =>
63
+ join('dist', projectRoot, 'descriptor.json');
64
+
65
+ /**
66
+ * The media directory the project declares, for the target's inputs. A
67
+ * project that keeps its media somewhere else still has to invalidate the
68
+ * cache when it changes.
69
+ */
70
+ const readDeclared = (projectRoot) => {
71
+ const dotKos = resolve(projectRoot, '.kos.json');
72
+ return existsSync(dotKos)
73
+ ? readJson(dotKos)?.kondra?.ui?.assets ?? {}
74
+ : {};
75
+ };
76
+
77
+ const mediaGlob = (declared) => mediaSource(declared).join('/');
78
+
79
+ /**
80
+ * Write a detected media location into the project's .kos.json, so a project
81
+ * that keeps its media somewhere other than the ui default is wired up
82
+ * without having to be told where it is.
83
+ *
84
+ * Only what can be established is written: `mediaSrc` comes off the disk,
85
+ * and `mediaRoot` off a build output, which is exact where guessing from the
86
+ * source path would be wrong for any project that rewrites paths on the way
87
+ * out. Nothing already declared is touched.
88
+ */
89
+ const detectLayout = (projectRoot, declared, outputDir) => {
90
+ const dotKos = resolve(projectRoot, '.kos.json');
91
+ if (!existsSync(dotKos)) {
92
+ return declared;
93
+ }
94
+
95
+ const found = { ...declared };
96
+ if (!found.mediaSrc) {
97
+ const detected = detectMediaSrc(projectRoot);
98
+ if (detected) {
99
+ found.mediaSrc = detected;
100
+ console.log(`kos-assets: found media in ${detected}`);
101
+ }
102
+ }
103
+ if (found.mediaSrc && found.mediaRoot === undefined) {
104
+ const root = detectMediaRoot(
105
+ projectRoot,
106
+ mediaSource(found),
107
+ resolve(outputDir)
108
+ );
109
+ if (root === undefined) {
110
+ console.warn(
111
+ `kos-assets: could not tell where ${found.mediaSrc} ends up inside ` +
112
+ `the kab — build the project and run init again, or set ` +
113
+ `kondra.ui.assets.mediaRoot in .kos.json`
114
+ );
115
+ } else {
116
+ found.mediaRoot = root;
117
+ console.log(`kos-assets: it is served from ${root || '/'} in the kab`);
118
+ }
119
+ }
120
+
121
+ if (found.mediaSrc === declared.mediaSrc &&
122
+ found.mediaRoot === declared.mediaRoot) {
123
+ return declared;
124
+ }
125
+
126
+ const kos = readJson(dotKos);
127
+ kos.kondra = {
128
+ ...kos.kondra,
129
+ ui: { ...kos.kondra?.ui, assets: { ...kos.kondra?.ui?.assets, ...found } },
130
+ };
131
+ writeJson(dotKos, kos);
132
+ console.log(`kos-assets: recorded the media location in ${dotKos}`);
133
+ return found;
134
+ };
135
+
136
+ /**
137
+ * Where the project's descriptor target writes, which is not always
138
+ * `dist/<projectRoot>` -- content projects in particular often point
139
+ * somewhere else, and the assets target has to follow it.
140
+ */
141
+ const descriptorTarget = (project, projectRoot) => {
142
+ const options = project.targets?.descriptor?.options;
143
+ if (!options?.outputPath) {
144
+ return defaultDescriptor(projectRoot);
145
+ }
146
+ const dir = options.outputPath.replace('{projectRoot}', projectRoot);
147
+ return join(dir, options.fileName ?? 'descriptor.json');
148
+ };
149
+
150
+ const build = (projectRoot, out) => {
151
+ const descriptorPath = resolve(out ?? defaultDescriptor(projectRoot));
31
152
  if (!existsSync(descriptorPath)) {
32
153
  fail(
33
154
  `no descriptor at ${descriptorPath} — run the descriptor target first, or pass --out`
@@ -40,6 +161,8 @@ const build = (projectRoot, out) => {
40
161
  return;
41
162
  }
42
163
 
164
+ verifyContents(assets, dirname(descriptorPath));
165
+
43
166
  const descriptor = readJson(descriptorPath);
44
167
  descriptor.kondra = {
45
168
  ...descriptor.kondra,
@@ -81,17 +204,30 @@ const init = (projectRoot) => {
81
204
 
82
205
  const project = readJson(path);
83
206
  project.targets = project.targets ?? {};
207
+
208
+ // follow the project's own descriptor target rather than assuming where it
209
+ // writes, so a content project is wired correctly without a manual --out
210
+ const descriptor = descriptorTarget(project, projectRoot);
211
+ const out =
212
+ descriptor === defaultDescriptor(projectRoot) ? '' : ` --out ${descriptor}`;
213
+
214
+ const declared = detectLayout(
215
+ projectRoot,
216
+ readDeclared(projectRoot),
217
+ dirname(descriptor)
218
+ );
219
+
84
220
  // the declaration is written into a build output, so nx needs to know what
85
221
  // it reads and what it produces — otherwise a restored build can be paired
86
222
  // with a freshly scanned declaration and the kab ships a mismatch
87
223
  project.targets.assets = {
88
- command: `${invocation()} build ${projectRoot}`,
224
+ command: `${invocation()} build ${projectRoot}${out}`,
89
225
  dependsOn: ['descriptor'],
90
226
  inputs: [
91
- `{projectRoot}/${SOURCE_DIR.join('/')}/**/*`,
227
+ `{projectRoot}/${mediaGlob(declared)}/**/*`,
92
228
  '{projectRoot}/.kos.json',
93
229
  ],
94
- outputs: ['{workspaceRoot}/dist/{projectRoot}/descriptor.json'],
230
+ outputs: [`{workspaceRoot}/${descriptor}`],
95
231
  };
96
232
 
97
233
  const zip = project.targets.zip;
@@ -12,6 +12,26 @@ import { join, resolve } from 'node:path';
12
12
 
13
13
  const SOURCE_DIR = ['src', 'assets', 'media'];
14
14
  const ASSETS_ROOT = '/assets/media';
15
+
16
+ /**
17
+ * Where a project keeps its media, and where that media ends up inside the
18
+ * kab. They are two different things -- the project's build step decides the
19
+ * mapping between them, and a content project using vite's `publicDir` drops
20
+ * the directory name on the way out -- so neither can be derived from the
21
+ * other. A project that doesn't say gets the ui defaults.
22
+ *
23
+ * "assets": { "mediaSrc": "assets/logos", "mediaRoot": "/logos" }
24
+ */
25
+ const mediaSource = (declared) =>
26
+ declared.mediaSrc ? declared.mediaSrc.split('/').filter(Boolean) : SOURCE_DIR;
27
+
28
+ const mediaRoot = (declared) => {
29
+ if (!declared.mediaRoot) {
30
+ return declared.mediaSrc ? '' : ASSETS_ROOT;
31
+ }
32
+ const trimmed = declared.mediaRoot.replace(/\/+$/, '');
33
+ return trimmed === '/' || trimmed === '' ? '' : `/${trimmed.replace(/^\/+/, '')}`;
34
+ };
15
35
  const TYPES = {
16
36
  '.avif': 'image/avif',
17
37
  '.gif': 'image/gif',
@@ -243,6 +263,114 @@ const boundKey = (rel, bindings) => {
243
263
  return undefined;
244
264
  };
245
265
 
266
+ /** Never a project's own media, and expensive to walk. */
267
+ const SKIP_DIRS = new Set([
268
+ 'node_modules',
269
+ 'dist',
270
+ 'tmp',
271
+ 'coverage',
272
+ '.git',
273
+ '.nx',
274
+ '.kos',
275
+ ]);
276
+
277
+ /** Every media file below a directory, by extension, with its path. */
278
+ const scanMedia = (dir, prefix = '') =>
279
+ readdirSync(dir, { withFileTypes: true }).flatMap((item) => {
280
+ if (item.name.startsWith('.') || SKIP_DIRS.has(item.name)) return [];
281
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
282
+ if (item.isDirectory()) return scanMedia(join(dir, item.name), rel);
283
+ const ext = item.name.slice(item.name.lastIndexOf('.')).toLowerCase();
284
+ return TYPES[ext] ? [rel] : [];
285
+ });
286
+
287
+ /** The deepest directory every one of these paths sits under. */
288
+ const commonDir = (paths) => {
289
+ const segments = paths.map((path) => path.split('/').slice(0, -1));
290
+ const [first = [], ...rest] = segments;
291
+ let shared = first;
292
+ for (const other of rest) {
293
+ let i = 0;
294
+ while (i < shared.length && shared[i] === other[i]) i += 1;
295
+ shared = shared.slice(0, i);
296
+ }
297
+ return shared.join('/');
298
+ };
299
+
300
+ /** True when this directory holds media, or leads to some. */
301
+ const holdsMedia = (dir) => scanMedia(dir).length > 0;
302
+
303
+ /**
304
+ * The common ancestor of a project's media is too deep when the media
305
+ * happens to sit in one subfolder -- a brandset with a single brand would be
306
+ * read as that brand. So climb back up while the parent holds nothing but
307
+ * media, which stops at the directory the project actually treats as its
308
+ * media root: the one with something else beside it.
309
+ */
310
+ const climbToMedia = (projectRoot, dir) => {
311
+ const segments = dir.split('/').filter(Boolean);
312
+ while (segments.length > 1) {
313
+ const parent = segments.slice(0, -1);
314
+ const entries = readdirSync(resolve(projectRoot, ...parent), {
315
+ withFileTypes: true,
316
+ }).filter(
317
+ (item) => !item.name.startsWith('.') && !SKIP_DIRS.has(item.name)
318
+ );
319
+ const onlyMedia = entries.every((item) =>
320
+ item.isDirectory()
321
+ ? holdsMedia(resolve(projectRoot, ...parent, item.name))
322
+ : TYPES[item.name.slice(item.name.lastIndexOf('.')).toLowerCase()]
323
+ );
324
+ if (!onlyMedia) {
325
+ break;
326
+ }
327
+ segments.pop();
328
+ }
329
+ return segments.join('/');
330
+ };
331
+
332
+ /**
333
+ * Where a project already keeps its media, for a project that hasn't said.
334
+ * Returns undefined when it is already in the ui default place, or when
335
+ * there is no media to find.
336
+ */
337
+ const detectMediaSrc = (projectRoot) => {
338
+ if (existsSync(resolve(projectRoot, ...SOURCE_DIR))) {
339
+ return undefined;
340
+ }
341
+ const found = scanMedia(resolve(projectRoot));
342
+ if (found.length === 0) {
343
+ return undefined;
344
+ }
345
+ const dir = commonDir(found);
346
+ return dir === '' ? undefined : climbToMedia(projectRoot, dir);
347
+ };
348
+
349
+ /**
350
+ * Where a media directory ends up inside the kab, read off a build output
351
+ * rather than guessed: find a file the build already copied and take the
352
+ * path it was copied to. Undefined when the project hasn't been built, or
353
+ * when the build doesn't carry the media at all.
354
+ */
355
+ const detectMediaRoot = (projectRoot, source, outputDir) => {
356
+ const mediaDir = resolve(projectRoot, ...source);
357
+ if (!existsSync(mediaDir) || !existsSync(outputDir)) {
358
+ return undefined;
359
+ }
360
+ const [sample] = scanMedia(mediaDir);
361
+ if (!sample) {
362
+ return undefined;
363
+ }
364
+ const built = scanMedia(resolve(outputDir)).filter(
365
+ (path) => path === sample || path.endsWith(`/${sample}`)
366
+ );
367
+ if (built.length !== 1) {
368
+ return undefined;
369
+ }
370
+ const root = built[0].slice(0, built[0].length - sample.length);
371
+ return root === '' ? '' : `/${root.replace(/\/+$/, '')}`;
372
+ };
373
+
246
374
  const fail = (message) => {
247
375
  console.error(`kos-assets: ${message}`);
248
376
  process.exit(1);
@@ -269,16 +397,22 @@ const walk = (dir, prefix = '') =>
269
397
  * groups and preload flags are authored in .kos.json and carried through.
270
398
  */
271
399
  const buildAssets = (projectRoot) => {
272
- const mediaDir = resolve(projectRoot, ...SOURCE_DIR);
273
- if (!existsSync(mediaDir)) {
274
- return undefined;
275
- }
276
-
277
400
  const dotKos = resolve(projectRoot, '.kos.json');
278
401
  const declared = existsSync(dotKos)
279
402
  ? readJson(dotKos)?.kondra?.ui?.assets ?? {}
280
403
  : {};
281
404
 
405
+ const source = mediaSource(declared);
406
+ const mediaDir = resolve(projectRoot, ...source);
407
+ if (!existsSync(mediaDir)) {
408
+ // a project that named its media directory meant it, so a typo there is
409
+ // a build error rather than a project that quietly declares nothing
410
+ if (declared.mediaSrc) {
411
+ fail(`no media directory at ${join(projectRoot, ...source)} (mediaSrc)`);
412
+ }
413
+ return undefined;
414
+ }
415
+
282
416
  const bindings = declared.keys ?? {};
283
417
  const markers = readMarkers(mediaDir);
284
418
  const claimed = new Set();
@@ -379,7 +513,7 @@ const buildAssets = (projectRoot) => {
379
513
  }
380
514
 
381
515
  return {
382
- root: ASSETS_ROOT,
516
+ root: mediaRoot(declared),
383
517
  entries,
384
518
  ...(Object.keys(groups).length > 0 ? { groups } : {}),
385
519
  };
@@ -390,6 +524,13 @@ export {
390
524
  SOURCE_DIR,
391
525
  ASSETS_ROOT,
392
526
  TYPES,
527
+ mediaSource,
528
+ mediaRoot,
529
+ scanMedia,
530
+ commonDir,
531
+ climbToMedia,
532
+ detectMediaSrc,
533
+ detectMediaRoot,
393
534
  parseMarker,
394
535
  readMarkers,
395
536
  bindingMatcher,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kosdev-code/kos-asset-manager",
3
- "version": "0.0.1-next.29",
3
+ "version": "0.0.1-next.30",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "kos": {
27
27
  "build": {
28
- "gitHash": "1b2fc996563150e3da055a9506a28b7cb132c060"
28
+ "gitHash": "630400cd67fd24b5cd069b7661dae83277c18934"
29
29
  }
30
30
  },
31
31
  "publishConfig": {