@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.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # @kosdev-code/kos-asset-manager
2
+
3
+ Normalized access to images, video and other media that applications declare
4
+ in their kab descriptors and the asset manager application serves.
5
+
6
+ ## Declaring assets in a ui project
7
+
8
+ Put files under `src/assets/media` in the project. Everything below it is
9
+ declared automatically, keyed by its path without the extension, so
10
+ `src/assets/media/brand/logo-primary.svg` is the key `brand/logo-primary`.
11
+
12
+ A key derived from a path breaks when the file moves, so pin the ones a ui
13
+ depends on. Bindings live in the project's `.kos.json`, take a path relative
14
+ to the media directory, and may capture with `{name}` or span folders with
15
+ `**`:
16
+
17
+ ```json
18
+ {
19
+ "kondra": {
20
+ "ui": {
21
+ "assets": {
22
+ "keys": {
23
+ "brand/logo-primary": "brand/logos/primary.svg",
24
+ "beverages/{name}": "drinks/{name}.svg",
25
+ "video/globe": "**/globe.mp4"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Bindings can also sit beside the files they name, in a `.kosassets` file in
34
+ any folder under the media directory. Its sources are relative to that
35
+ folder while its keys stay absolute, so renaming or moving the folder
36
+ carries the rules with it and no key changes:
37
+
38
+ ```
39
+ # src/assets/media/brand/.kosassets
40
+ brand/logo-primary = logos/primary.svg
41
+ brand/{name} = *.svg
42
+ ```
43
+
44
+ A file takes the first key that claims it: `.kos.json`, then the nearest
45
+ `.kosassets` walking up, then its own path. A binding that matches no file
46
+ is reported during the build.
47
+
48
+ Renaming a key is a breaking change for whoever uses it, so the old key can
49
+ be kept alive alongside the new one:
50
+
51
+ ```json
52
+ "entries": { "brand/logo-primary": { "aliases": ["brand/kos-logo"] } }
53
+ ```
54
+
55
+ Lookups try the key first and then the aliases, which gives consumers time
56
+ to catch up before the alias is dropped.
57
+
58
+ Add the target that generates the declaration:
59
+
60
+ ```sh
61
+ npx kos-assets init apps/my-app
62
+ ```
63
+
64
+ This adds an `assets` target that runs after `descriptor`, and makes `zip`
65
+ depend on it so the declaration is in the descriptor before the kab is built.
66
+ To wire it by hand instead, add to the project's `project.json`:
67
+
68
+ ```json
69
+ "assets": {
70
+ "command": "kos-assets build apps/my-app",
71
+ "dependsOn": ["descriptor"],
72
+ "inputs": [
73
+ "{projectRoot}/src/assets/media/**/*",
74
+ "{projectRoot}/.kos.json"
75
+ ],
76
+ "outputs": ["{workspaceRoot}/dist/{projectRoot}/descriptor.json"]
77
+ }
78
+ ```
79
+
80
+ and add `"assets"` to the `zip` target's `dependsOn`. The inputs and outputs
81
+ matter: the declaration is written into a build output, so without them a
82
+ restored build can be paired with a freshly scanned declaration and the kab
83
+ ships assets it declares but does not contain.
84
+
85
+ Video gets its thumbnail from an image that shares its key: drop
86
+ `video/pour-loop.mp4` and `video/pour-loop-poster.png` in the same folder and
87
+ they are paired automatically (`-poster` or `-still`). Name one explicitly
88
+ when the convention doesn't fit:
89
+
90
+ ```json
91
+ "entries": { "video/pour-loop": { "poster": "brand/hero-still" } }
92
+ ```
93
+
94
+ The poster is an ordinary asset, so it is measured, preloaded and decoded
95
+ like any other image. `wrapAsset('video/pour-loop').poster` is its url.
96
+
97
+ Video is measured from its container for mp4, mov and m4v; anything else
98
+ takes the dimensions of its poster, so a video reserves its space either
99
+ way.
100
+
101
+ Groups, tags and eager-loading are authored in the project's `.kos.json`.
102
+ Groups and tags take patterns rather than lists of files -- `*` matches
103
+ within a path segment and `**` across them -- and are expanded into concrete
104
+ keys when the declaration is generated:
105
+
106
+ ```json
107
+ {
108
+ "kondra": {
109
+ "ui": {
110
+ "assets": {
111
+ "tags": { "beverage-logo": ["beverages/**"] },
112
+ "groups": { "boot": ["brand/logo-primary", "beverages/**"] },
113
+ "entries": { "brand/logo-primary": { "preload": true } }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ A pattern that matches nothing is reported during the build.
121
+
122
+ ## Using assets in a ui
123
+
124
+ ```ts
125
+ import { initAssets, wrapAsset, resolveAsset, preloadAssets } from '@kosdev-code/kos-asset-manager';
126
+
127
+ // once while starting, naming the applications whose assets this ui uses
128
+ await initAssets({ contexts: ['my.app.id'], preload: ['boot'] });
129
+
130
+ const logo = wrapAsset('brand/logo-primary');
131
+ <img src={logo.src} alt="" />
132
+
133
+ // anywhere a url is expected
134
+ element.style.backgroundImage = `url(${resolveAsset('brand/logo-primary')})`;
135
+
136
+ // ready assets ahead of a screen that needs them
137
+ await preloadAssets('checkout');
138
+ ```
139
+
140
+ Sets of assets are selected rather than named, so adding a file to a folder
141
+ is all it takes to include it:
142
+
143
+ ```ts
144
+ await preloadAssets({ prefix: 'beverages/' }); // a folder
145
+ await preloadAssets({ tag: 'beverage-logo' }); // a tag, across folders
146
+ await preloadAssets({ type: 'video/' }); // by mime type
147
+
148
+ listAssets({ tag: 'beverage-logo' }).map((asset) => asset.src);
149
+ ```
150
+
151
+ Selector fields combine, so `{ prefix: 'brand/', type: 'image/svg+xml' }` is
152
+ the svg files in that folder.
153
+
154
+ ## Preloading during bootstrap
155
+
156
+ Preloading readies an image by fetching *and decoding* it, and holds the
157
+ decoded image so the first paint has nothing left to do. Assets are served
158
+ from the device over loopback, so the decode is the part that costs time --
159
+ which is what makes images pop into view on lower powered hardware. Paying
160
+ for it during a bootstrap sequence trades a couple of seconds at startup for
161
+ screens that render immediately afterwards.
162
+
163
+ ```ts
164
+ await preloadAssets({ prefix: 'beverages/' }, {
165
+ concurrency: 4,
166
+ onProgress: ({ done, total }) => setBootProgress(done / total),
167
+ });
168
+ ```
169
+
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:
177
+
178
+ ```tsx
179
+ const logo = wrapAsset('brand/logo-primary');
180
+ <img src={logo.src} width={logo.width} height={logo.height} alt="" />
181
+ ```
182
+
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.
186
+
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`.
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Declares the ui assets of a kos project.
4
+ *
5
+ * kos-assets build <projectRoot> [--out <descriptor.json>]
6
+ * kos-assets init <projectRoot>
7
+ *
8
+ * `build` scans a fixed directory in the project and merges a
9
+ * kondra.ui.assets block into the descriptor the kab is built from. `init`
10
+ * adds the target that runs it to the project.
11
+ */
12
+ import { existsSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+
15
+ import {
16
+ SOURCE_DIR,
17
+ buildAssets,
18
+ readJson,
19
+ writeJson,
20
+ } from './lib/declare-assets.mjs';
21
+
22
+ const fail = (message) => {
23
+ console.error(`kos-assets: ${message}`);
24
+ process.exit(1);
25
+ };
26
+
27
+ const build = (projectRoot, out) => {
28
+ const descriptorPath = resolve(
29
+ out ?? join('dist', projectRoot, 'descriptor.json')
30
+ );
31
+ if (!existsSync(descriptorPath)) {
32
+ fail(
33
+ `no descriptor at ${descriptorPath} — run the descriptor target first, or pass --out`
34
+ );
35
+ }
36
+
37
+ const assets = buildAssets(resolve(projectRoot));
38
+ if (!assets) {
39
+ console.log(`kos-assets: no assets in ${join(projectRoot, ...SOURCE_DIR)}`);
40
+ return;
41
+ }
42
+
43
+ const descriptor = readJson(descriptorPath);
44
+ descriptor.kondra = {
45
+ ...descriptor.kondra,
46
+ ui: { ...descriptor.kondra?.ui, assets },
47
+ };
48
+ writeJson(descriptorPath, descriptor);
49
+ console.log(
50
+ `kos-assets: declared ${Object.keys(assets.entries).length} assets in ${descriptorPath}`
51
+ );
52
+ };
53
+
54
+ /**
55
+ * Add the assets target to a project and make the archive depend on it, so
56
+ * the block is in the descriptor before the kab is zipped.
57
+ */
58
+ /**
59
+ * How the target should invoke this tool. A project that consumes the
60
+ * published package gets it from node_modules; the workspace that owns the
61
+ * source runs it from there, since nothing links a bin for a local lib.
62
+ */
63
+ const invocation = () => {
64
+ const local = join(
65
+ 'libs',
66
+ 'kos-asset-manager-models',
67
+ 'bin',
68
+ 'kos-assets.mjs'
69
+ );
70
+ if (existsSync(resolve(local))) {
71
+ return `node ${local}`;
72
+ }
73
+ return 'npx kos-assets';
74
+ };
75
+
76
+ const init = (projectRoot) => {
77
+ const path = resolve(projectRoot, 'project.json');
78
+ if (!existsSync(path)) {
79
+ fail(`no project.json in ${projectRoot}`);
80
+ }
81
+
82
+ const project = readJson(path);
83
+ project.targets = project.targets ?? {};
84
+ // the declaration is written into a build output, so nx needs to know what
85
+ // it reads and what it produces — otherwise a restored build can be paired
86
+ // with a freshly scanned declaration and the kab ships a mismatch
87
+ project.targets.assets = {
88
+ command: `${invocation()} build ${projectRoot}`,
89
+ dependsOn: ['descriptor'],
90
+ inputs: [
91
+ `{projectRoot}/${SOURCE_DIR.join('/')}/**/*`,
92
+ '{projectRoot}/.kos.json',
93
+ ],
94
+ outputs: ['{workspaceRoot}/dist/{projectRoot}/descriptor.json'],
95
+ };
96
+
97
+ const zip = project.targets.zip;
98
+ if (zip && !(zip.dependsOn ?? []).includes('assets')) {
99
+ zip.dependsOn = [...(zip.dependsOn ?? []), 'assets'];
100
+ }
101
+
102
+ writeJson(path, project);
103
+ console.log(`kos-assets: added the assets target to ${path}`);
104
+ };
105
+
106
+ const [command, projectRoot, ...rest] = process.argv.slice(2);
107
+ if (!command || !projectRoot) {
108
+ fail('usage: kos-assets <build|init> <projectRoot> [--out <descriptor.json>]');
109
+ }
110
+
111
+ const outIndex = rest.indexOf('--out');
112
+ const out = outIndex === -1 ? undefined : rest[outIndex + 1];
113
+
114
+ switch (command) {
115
+ case 'build':
116
+ build(projectRoot, out);
117
+ break;
118
+ case 'init':
119
+ init(projectRoot);
120
+ break;
121
+ default:
122
+ fail(`unknown command: ${command}`);
123
+ }