@kosdev-code/kos-asset-manager 0.0.1-next.3 → 0.0.1-next.31

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,37 +119,211 @@ keys when the declaration is generated:
119
119
 
120
120
  A pattern that matches nothing is reported during the build.
121
121
 
122
- ## Using assets in a ui
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
+
187
+ ## Installing
188
+
189
+ ```sh
190
+ npm install @kosdev-code/kos-asset-manager
191
+ ```
192
+
193
+ The package is ESM. `@kosdev-code/kos-ui-sdk` (`~3.0.0`) and React 18 or
194
+ newer are peer dependencies, so it uses the copies the application already
195
+ has.
196
+
197
+ ## Setting it up
198
+
199
+ Register the models where the application registers everything else. Note the
200
+ second call — `registerAssetModels` returns a function.
123
201
 
124
202
  ```ts
125
- import { initAssets, wrapAsset, resolveAsset, preloadAssets } from '@kosdev-code/kos-asset-manager';
203
+ import { KosModelRegistry } from '@kosdev-code/kos-dispense-sdk';
204
+ import { registerAssetModels } from '@kosdev-code/kos-asset-manager';
205
+
206
+ KosModelRegistry.dispense.models();
207
+ registerAssetModels(KosModelRegistry)();
208
+ ```
209
+
210
+ Then wrap the tree in the provider, which loads the declarations everything
211
+ else reads:
212
+
213
+ ```tsx
214
+ import { KosAssetProvider } from '@kosdev-code/kos-asset-manager';
215
+
216
+ const App = () => (
217
+ <KosTranslationProvider>
218
+ <KosAssetProvider contexts={['my.app.id']} gate>
219
+ <MyScreens />
220
+ </KosAssetProvider>
221
+ </KosTranslationProvider>
222
+ );
223
+ ```
224
+
225
+ `contexts` names the applications whose assets this ui uses, so it loads only
226
+ what it needs; omit it to load everything the device declares, which is what
227
+ a tool that browses them wants. `gate` holds the children back until the
228
+ assets have loaded, which is worth it when the first screen renders them.
229
+
230
+ `contexts` is also the tie-break order for a bare key: named contexts are
231
+ searched first, in the order given, then everything else.
232
+
233
+ For an application that would rather drive its own loading UI,
234
+ `useAssetManager(contexts?)` is what the provider uses internally and returns
235
+ `{ model, ready, status, KosModelLoader }`. `useKosAssetContext()` returns
236
+ `{ model }` for components that want the model rather than the helpers.
126
237
 
127
- // once while starting, naming the applications whose assets this ui uses
128
- await initAssets({ contexts: ['my.app.id'], preload: ['boot'] });
238
+ ## Using assets
129
239
 
130
- const logo = wrapAsset('brand/logo-primary');
131
- <img src={logo.src} alt="" />
240
+ ```tsx
241
+ import { kosComponent } from '@kosdev-code/kos-ui-sdk';
242
+ import { wrapAsset } from '@kosdev-code/kos-asset-manager';
243
+
244
+ export const Header = kosComponent(() => {
245
+ const logo = wrapAsset('brand/logo-primary');
246
+
247
+ return (
248
+ <img src={logo.src} width={logo.width} height={logo.height} alt="Home" />
249
+ );
250
+ });
251
+ ```
132
252
 
133
- // anywhere a url is expected
253
+ `wrapAsset(key)` returns a `ResolvedAsset`: `src`, `width`, `height`,
254
+ `poster`, `type`, `size`, `hash`, `tags`, `key`, `context` and `exists`.
255
+ Images are measured when the declaration is generated, so passing `width` and
256
+ `height` reserves the space and nothing reflows when the file arrives. An
257
+ unknown key logs a warning and comes back with `exists: false` and an empty
258
+ `src` rather than throwing, so a missing asset never takes a screen down.
259
+
260
+ Two things to get right. The helpers read MobX models, so a component that
261
+ reads them should be a `kosComponent` — otherwise it renders once with
262
+ whatever was loaded at the time and never updates. And resolve inside the
263
+ component rather than at module scope: a value computed when the module loads
264
+ runs before the provider has loaded anything.
265
+
266
+ `resolveAsset(key)` is the url on its own, for anywhere a url is expected:
267
+
268
+ ```ts
134
269
  element.style.backgroundImage = `url(${resolveAsset('brand/logo-primary')})`;
270
+ ```
271
+
272
+ `hasAsset(key)` is true when a loaded application declares the key, for
273
+ branching on an optional asset without the warning `wrapAsset` logs.
274
+
275
+ A key resolves in the contexts named at the provider, first match winning,
276
+ unless it names one: `other.app.id:brand/logo-primary`.
277
+
278
+ ## Loading data in a model
279
+
280
+ Json is declared like any other asset, which makes it fetchable by key:
135
281
 
136
- // ready assets ahead of a screen that needs them
137
- await preloadAssets('checkout');
282
+ ```ts
283
+ import { loadAsset } from '@kosdev-code/kos-asset-manager';
284
+
285
+ @kosModel({ modelTypeId: MODEL_TYPE })
286
+ export class MenuModelImpl {
287
+ async load(): Promise<void> {
288
+ const menu = await loadAsset<MenuDto>('config/menu');
289
+ if (!menu) return;
290
+ this.reconcile(menu.items);
291
+ }
292
+ }
138
293
  ```
139
294
 
295
+ Data usually has to be parsed and normalised into a model's own shape
296
+ anyway, so this is deliberately a plain async call rather than something a
297
+ component reads. The point is the key: the file can be renamed, moved, or
298
+ moved into a different kab without the model changing.
299
+
300
+ Undefined comes back when the key isn't declared, the fetch fails, or the
301
+ content isn't valid json, each logged with the key — so a model handles it
302
+ the way it already handles a service returning nothing. `loadAssetText(key)`
303
+ is the same without the parse.
304
+
305
+ Json files are declared and keyed like anything else, but they don't count
306
+ as media when `kos-assets init` works out where a project keeps its media —
307
+ a manifest sitting beside a folder of images is what marks the images as the
308
+ media, not the whole folder.
309
+
310
+ ## Selecting sets
311
+
140
312
  Sets of assets are selected rather than named, so adding a file to a folder
141
313
  is all it takes to include it:
142
314
 
143
315
  ```ts
316
+ listAssets({ tag: 'beverage-logo' }).map((asset) => asset.src);
317
+
144
318
  await preloadAssets({ prefix: 'beverages/' }); // a folder
145
319
  await preloadAssets({ tag: 'beverage-logo' }); // a tag, across folders
146
320
  await preloadAssets({ type: 'video/' }); // by mime type
147
-
148
- listAssets({ tag: 'beverage-logo' }).map((asset) => asset.src);
149
321
  ```
150
322
 
151
- Selector fields combine, so `{ prefix: 'brand/', type: 'image/svg+xml' }` is
152
- the svg files in that folder.
323
+ An `AssetSelector` has `context`, `prefix`, `tag`, `type` and `keys`, and the
324
+ fields combine — `{ prefix: 'brand/', type: 'image/svg+xml' }` is the svg
325
+ files in that folder. `listAssets()` with no selector is everything loaded,
326
+ and `contextNames()` is the application ids that declared assets.
153
327
 
154
328
  ## Preloading during bootstrap
155
329
 
@@ -160,6 +334,15 @@ which is what makes images pop into view on lower powered hardware. Paying
160
334
  for it during a bootstrap sequence trades a couple of seconds at startup for
161
335
  screens that render immediately afterwards.
162
336
 
337
+ `preloadAssets` takes a group name, a key, an array of either, or a selector:
338
+
339
+ ```ts
340
+ await preloadAssets('boot'); // a group from the declaration
341
+ await preloadAssets('brand/logo-primary'); // one key
342
+ await preloadAssets(['boot', 'checkout']); // several
343
+ await preloadAssets({ prefix: 'beverages/' }); // a selector
344
+ ```
345
+
163
346
  ```ts
164
347
  await preloadAssets({ prefix: 'beverages/' }, {
165
348
  concurrency: 4,
@@ -167,23 +350,19 @@ await preloadAssets({ prefix: 'beverages/' }, {
167
350
  });
168
351
  ```
169
352
 
170
- Assets are readied a few at a time, because a wide fan out on a slow device
171
- only thrashes. A single asset that cannot be readied is reported through
172
- `onProgress` with `ok: false` and never fails the sequence, so one bad file
173
- cannot stop a device from starting.
174
-
175
- Images are measured when the declaration is generated, so the space they
176
- take can be reserved and nothing reflows when they arrive:
353
+ Assets are readied a few at a time (`concurrency`, 4 by default), because a
354
+ wide fan out on a slow device only thrashes. A single asset that cannot be
355
+ readied is reported through `onProgress` with `ok: false` and never fails the
356
+ sequence, so one bad file cannot stop a device from starting.
177
357
 
178
- ```tsx
179
- const logo = wrapAsset('brand/logo-primary');
180
- <img src={logo.src} width={logo.width} height={logo.height} alt="" />
181
- ```
358
+ `preloadDeclared(options?)` readies every loaded asset whose declaration set
359
+ `preload: true`, so the application doesn't have to know which those are.
360
+ `releaseAssets()` drops the decoded images preloading is holding — worth
361
+ calling when leaving a section whose assets are large and won't be shown
362
+ again.
182
363
 
183
- `{ hold: true }` additionally keeps the bytes as an object url, which is
184
- worth it only when an asset must survive the content going away.
185
- `releaseAssets()` drops everything preloading is holding.
364
+ ## Where the files come from
186
365
 
187
- Only the contexts named in `initAssets` are requested. Keys resolve in the
188
- default context (the first one declared) unless they name another, as in
189
- `other.app.id:brand/logo-primary`.
366
+ Urls are built against the `host` query parameter when there is one, and the
367
+ page's own origin otherwise, so a ui run on a laptop against a device
368
+ resolves assets by opening it with `?host=http://device-address`.
@@ -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',
@@ -25,6 +45,21 @@ const TYPES = {
25
45
  '.webm': 'video/webm',
26
46
  '.webp': 'image/webp',
27
47
  '.woff2': 'font/woff2',
48
+ '.json': 'application/json',
49
+ };
50
+
51
+ /**
52
+ * Data assets are declared like any other, but they are not what makes a
53
+ * directory a project's media directory -- a brandset keeps its manifest
54
+ * beside its images, and it is the manifest sitting there that tells you the
55
+ * images are the media rather than the whole folder. So layout detection
56
+ * looks past them.
57
+ */
58
+ const DATA_TYPES = new Set(['.json']);
59
+
60
+ const isMedia = (name) => {
61
+ const ext = name.slice(name.lastIndexOf('.')).toLowerCase();
62
+ return Boolean(TYPES[ext]) && !DATA_TYPES.has(ext);
28
63
  };
29
64
 
30
65
  /**
@@ -243,6 +278,113 @@ const boundKey = (rel, bindings) => {
243
278
  return undefined;
244
279
  };
245
280
 
281
+ /** Never a project's own media, and expensive to walk. */
282
+ const SKIP_DIRS = new Set([
283
+ 'node_modules',
284
+ 'dist',
285
+ 'tmp',
286
+ 'coverage',
287
+ '.git',
288
+ '.nx',
289
+ '.kos',
290
+ ]);
291
+
292
+ /** Every media file below a directory, by extension, with its path. */
293
+ const scanMedia = (dir, prefix = '') =>
294
+ readdirSync(dir, { withFileTypes: true }).flatMap((item) => {
295
+ if (item.name.startsWith('.') || SKIP_DIRS.has(item.name)) return [];
296
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
297
+ if (item.isDirectory()) return scanMedia(join(dir, item.name), rel);
298
+ return isMedia(item.name) ? [rel] : [];
299
+ });
300
+
301
+ /** The deepest directory every one of these paths sits under. */
302
+ const commonDir = (paths) => {
303
+ const segments = paths.map((path) => path.split('/').slice(0, -1));
304
+ const [first = [], ...rest] = segments;
305
+ let shared = first;
306
+ for (const other of rest) {
307
+ let i = 0;
308
+ while (i < shared.length && shared[i] === other[i]) i += 1;
309
+ shared = shared.slice(0, i);
310
+ }
311
+ return shared.join('/');
312
+ };
313
+
314
+ /** True when this directory holds media, or leads to some. */
315
+ const holdsMedia = (dir) => scanMedia(dir).length > 0;
316
+
317
+ /**
318
+ * The common ancestor of a project's media is too deep when the media
319
+ * happens to sit in one subfolder -- a brandset with a single brand would be
320
+ * read as that brand. So climb back up while the parent holds nothing but
321
+ * media, which stops at the directory the project actually treats as its
322
+ * media root: the one with something else beside it.
323
+ */
324
+ const climbToMedia = (projectRoot, dir) => {
325
+ const segments = dir.split('/').filter(Boolean);
326
+ while (segments.length > 1) {
327
+ const parent = segments.slice(0, -1);
328
+ const entries = readdirSync(resolve(projectRoot, ...parent), {
329
+ withFileTypes: true,
330
+ }).filter(
331
+ (item) => !item.name.startsWith('.') && !SKIP_DIRS.has(item.name)
332
+ );
333
+ const onlyMedia = entries.every((item) =>
334
+ item.isDirectory()
335
+ ? holdsMedia(resolve(projectRoot, ...parent, item.name))
336
+ : isMedia(item.name)
337
+ );
338
+ if (!onlyMedia) {
339
+ break;
340
+ }
341
+ segments.pop();
342
+ }
343
+ return segments.join('/');
344
+ };
345
+
346
+ /**
347
+ * Where a project already keeps its media, for a project that hasn't said.
348
+ * Returns undefined when it is already in the ui default place, or when
349
+ * there is no media to find.
350
+ */
351
+ const detectMediaSrc = (projectRoot) => {
352
+ if (existsSync(resolve(projectRoot, ...SOURCE_DIR))) {
353
+ return undefined;
354
+ }
355
+ const found = scanMedia(resolve(projectRoot));
356
+ if (found.length === 0) {
357
+ return undefined;
358
+ }
359
+ const dir = commonDir(found);
360
+ return dir === '' ? undefined : climbToMedia(projectRoot, dir);
361
+ };
362
+
363
+ /**
364
+ * Where a media directory ends up inside the kab, read off a build output
365
+ * rather than guessed: find a file the build already copied and take the
366
+ * path it was copied to. Undefined when the project hasn't been built, or
367
+ * when the build doesn't carry the media at all.
368
+ */
369
+ const detectMediaRoot = (projectRoot, source, outputDir) => {
370
+ const mediaDir = resolve(projectRoot, ...source);
371
+ if (!existsSync(mediaDir) || !existsSync(outputDir)) {
372
+ return undefined;
373
+ }
374
+ const [sample] = scanMedia(mediaDir);
375
+ if (!sample) {
376
+ return undefined;
377
+ }
378
+ const built = scanMedia(resolve(outputDir)).filter(
379
+ (path) => path === sample || path.endsWith(`/${sample}`)
380
+ );
381
+ if (built.length !== 1) {
382
+ return undefined;
383
+ }
384
+ const root = built[0].slice(0, built[0].length - sample.length);
385
+ return root === '' ? '' : `/${root.replace(/\/+$/, '')}`;
386
+ };
387
+
246
388
  const fail = (message) => {
247
389
  console.error(`kos-assets: ${message}`);
248
390
  process.exit(1);
@@ -269,16 +411,22 @@ const walk = (dir, prefix = '') =>
269
411
  * groups and preload flags are authored in .kos.json and carried through.
270
412
  */
271
413
  const buildAssets = (projectRoot) => {
272
- const mediaDir = resolve(projectRoot, ...SOURCE_DIR);
273
- if (!existsSync(mediaDir)) {
274
- return undefined;
275
- }
276
-
277
414
  const dotKos = resolve(projectRoot, '.kos.json');
278
415
  const declared = existsSync(dotKos)
279
416
  ? readJson(dotKos)?.kondra?.ui?.assets ?? {}
280
417
  : {};
281
418
 
419
+ const source = mediaSource(declared);
420
+ const mediaDir = resolve(projectRoot, ...source);
421
+ if (!existsSync(mediaDir)) {
422
+ // a project that named its media directory meant it, so a typo there is
423
+ // a build error rather than a project that quietly declares nothing
424
+ if (declared.mediaSrc) {
425
+ fail(`no media directory at ${join(projectRoot, ...source)} (mediaSrc)`);
426
+ }
427
+ return undefined;
428
+ }
429
+
282
430
  const bindings = declared.keys ?? {};
283
431
  const markers = readMarkers(mediaDir);
284
432
  const claimed = new Set();
@@ -379,7 +527,7 @@ const buildAssets = (projectRoot) => {
379
527
  }
380
528
 
381
529
  return {
382
- root: ASSETS_ROOT,
530
+ root: mediaRoot(declared),
383
531
  entries,
384
532
  ...(Object.keys(groups).length > 0 ? { groups } : {}),
385
533
  };
@@ -390,6 +538,15 @@ export {
390
538
  SOURCE_DIR,
391
539
  ASSETS_ROOT,
392
540
  TYPES,
541
+ DATA_TYPES,
542
+ isMedia,
543
+ mediaSource,
544
+ mediaRoot,
545
+ scanMedia,
546
+ commonDir,
547
+ climbToMedia,
548
+ detectMediaSrc,
549
+ detectMediaRoot,
393
550
  parseMarker,
394
551
  readMarkers,
395
552
  bindingMatcher,
package/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export { registerAssetModels } from './registration';
5
5
  export { contextNames } from './runtime/assets';
6
6
  export { KosAssetProvider, KosAssetContext, useAssetManager, useKosAssetContext, } from './runtime/asset-provider';
7
7
  export { resolveAsset, wrapAsset, hasAsset, listAssets } from './runtime/resolve';
8
+ export { loadAsset, loadAssetText } from './runtime/data';
8
9
  export { preloadAssets, preloadDeclared, releaseAssets, } from './runtime/preload';
9
10
  export type { AssetSelector, PreloadOptions, PreloadProgress, ResolvedAsset, } from './runtime/types';
10
11
  //# sourceMappingURL=index.d.ts.map