@kosdev-code/kos-asset-manager 0.0.1-next.3 → 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 +178 -31
- package/bin/kos-assets.mjs +144 -8
- package/bin/lib/declare-assets.mjs +147 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -119,37 +119,179 @@ keys when the declaration is generated:
|
|
|
119
119
|
|
|
120
120
|
A pattern that matches nothing is reported during the build.
|
|
121
121
|
|
|
122
|
-
##
|
|
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 {
|
|
203
|
+
import { KosModelRegistry } from '@kosdev-code/kos-dispense-sdk';
|
|
204
|
+
import { registerAssetModels } from '@kosdev-code/kos-asset-manager';
|
|
126
205
|
|
|
127
|
-
|
|
128
|
-
|
|
206
|
+
KosModelRegistry.dispense.models();
|
|
207
|
+
registerAssetModels(KosModelRegistry)();
|
|
208
|
+
```
|
|
129
209
|
|
|
130
|
-
|
|
131
|
-
|
|
210
|
+
Then wrap the tree in the provider, which loads the declarations everything
|
|
211
|
+
else reads:
|
|
132
212
|
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
135
229
|
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
237
|
+
|
|
238
|
+
## Using assets
|
|
239
|
+
|
|
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
|
+
});
|
|
138
251
|
```
|
|
139
252
|
|
|
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
|
|
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
|
+
## Selecting sets
|
|
279
|
+
|
|
140
280
|
Sets of assets are selected rather than named, so adding a file to a folder
|
|
141
281
|
is all it takes to include it:
|
|
142
282
|
|
|
143
283
|
```ts
|
|
284
|
+
listAssets({ tag: 'beverage-logo' }).map((asset) => asset.src);
|
|
285
|
+
|
|
144
286
|
await preloadAssets({ prefix: 'beverages/' }); // a folder
|
|
145
287
|
await preloadAssets({ tag: 'beverage-logo' }); // a tag, across folders
|
|
146
288
|
await preloadAssets({ type: 'video/' }); // by mime type
|
|
147
|
-
|
|
148
|
-
listAssets({ tag: 'beverage-logo' }).map((asset) => asset.src);
|
|
149
289
|
```
|
|
150
290
|
|
|
151
|
-
|
|
152
|
-
|
|
291
|
+
An `AssetSelector` has `context`, `prefix`, `tag`, `type` and `keys`, and the
|
|
292
|
+
fields combine — `{ prefix: 'brand/', type: 'image/svg+xml' }` is the svg
|
|
293
|
+
files in that folder. `listAssets()` with no selector is everything loaded,
|
|
294
|
+
and `contextNames()` is the application ids that declared assets.
|
|
153
295
|
|
|
154
296
|
## Preloading during bootstrap
|
|
155
297
|
|
|
@@ -160,6 +302,15 @@ which is what makes images pop into view on lower powered hardware. Paying
|
|
|
160
302
|
for it during a bootstrap sequence trades a couple of seconds at startup for
|
|
161
303
|
screens that render immediately afterwards.
|
|
162
304
|
|
|
305
|
+
`preloadAssets` takes a group name, a key, an array of either, or a selector:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
await preloadAssets('boot'); // a group from the declaration
|
|
309
|
+
await preloadAssets('brand/logo-primary'); // one key
|
|
310
|
+
await preloadAssets(['boot', 'checkout']); // several
|
|
311
|
+
await preloadAssets({ prefix: 'beverages/' }); // a selector
|
|
312
|
+
```
|
|
313
|
+
|
|
163
314
|
```ts
|
|
164
315
|
await preloadAssets({ prefix: 'beverages/' }, {
|
|
165
316
|
concurrency: 4,
|
|
@@ -167,23 +318,19 @@ await preloadAssets({ prefix: 'beverages/' }, {
|
|
|
167
318
|
});
|
|
168
319
|
```
|
|
169
320
|
|
|
170
|
-
Assets are readied a few at a time
|
|
171
|
-
only thrashes. A single asset that cannot be
|
|
172
|
-
`onProgress` with `ok: false` and never fails the
|
|
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:
|
|
321
|
+
Assets are readied a few at a time (`concurrency`, 4 by default), because a
|
|
322
|
+
wide fan out on a slow device only thrashes. A single asset that cannot be
|
|
323
|
+
readied is reported through `onProgress` with `ok: false` and never fails the
|
|
324
|
+
sequence, so one bad file cannot stop a device from starting.
|
|
177
325
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
326
|
+
`preloadDeclared(options?)` readies every loaded asset whose declaration set
|
|
327
|
+
`preload: true`, so the application doesn't have to know which those are.
|
|
328
|
+
`releaseAssets()` drops the decoded images preloading is holding — worth
|
|
329
|
+
calling when leaving a section whose assets are large and won't be shown
|
|
330
|
+
again.
|
|
182
331
|
|
|
183
|
-
|
|
184
|
-
worth it only when an asset must survive the content going away.
|
|
185
|
-
`releaseAssets()` drops everything preloading is holding.
|
|
332
|
+
## Where the files come from
|
|
186
333
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
334
|
+
Urls are built against the `host` query parameter when there is one, and the
|
|
335
|
+
page's own origin otherwise, so a ui run on a laptop against a device
|
|
336
|
+
resolves assets by opening it with `?host=http://device-address`.
|
package/bin/kos-assets.mjs
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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}/${
|
|
227
|
+
`{projectRoot}/${mediaGlob(declared)}/**/*`,
|
|
92
228
|
'{projectRoot}/.kos.json',
|
|
93
229
|
],
|
|
94
|
-
outputs: [
|
|
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:
|
|
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.
|
|
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": "
|
|
28
|
+
"gitHash": "630400cd67fd24b5cd069b7661dae83277c18934"
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|