@ranx729/ducktape-assets 0.0.0 → 1.1.0
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/AGENTS.md +62 -0
- package/README.md +52 -2
- package/bin/ducktape-assets.mjs +34 -0
- package/catalog.json +6651 -0
- package/index.json +51 -0
- package/package.json +51 -1
- package/schemas/index.schema.json +38 -0
- package/sets/illuminated-acanthus.json +8202 -0
- package/sets/medieval-cutouts.json +8492 -0
- package/sets/walters-w102.json +759 -0
- package/src/copy.mjs +152 -0
- package/src/generate.mjs +200 -0
- package/src/node.mjs +21 -0
- package/src/resolver.mjs +248 -0
- package/src/rungs.mjs +34 -0
- package/src/thumbhash.mjs +163 -0
- package/src/urls.mjs +34 -0
package/src/copy.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Pick-and-copy flow: fetch exactly the chosen files into the project, verify their sha256, write ducktape.manifest.json.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { createResolver } from './resolver.mjs';
|
|
6
|
+
import { fsManifestLoader } from './node.mjs';
|
|
7
|
+
|
|
8
|
+
const isUrl = (s) => /^https?:\/\//.test(s);
|
|
9
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
10
|
+
const ASC = (a, b) => a.w - b.w;
|
|
11
|
+
|
|
12
|
+
/** Read a location that is either an http(s) URL or a local path. */
|
|
13
|
+
async function load(loc) {
|
|
14
|
+
if (isUrl(loc)) {
|
|
15
|
+
let last;
|
|
16
|
+
for (let i = 0; i < 3; i++) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(loc);
|
|
19
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
20
|
+
return Buffer.from(await res.arrayBuffer());
|
|
21
|
+
} catch (e) {
|
|
22
|
+
last = e;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
throw new Error(`${loc}: ${last.message}`);
|
|
26
|
+
}
|
|
27
|
+
return readFile(loc.replace(/^file:\/\//, ''));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const normalize = (a) => (typeof a === 'string' ? { id: a } : a);
|
|
31
|
+
|
|
32
|
+
/** The chosen files of one ladder: formats, then `widths` (smallest rung >= each, never upscale), then `max`. */
|
|
33
|
+
function chooseLadder(ladder, spec) {
|
|
34
|
+
const formats = spec.formats ?? ['avif', 'webp', 'svg'];
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const fmt of formats) {
|
|
37
|
+
const v = ladder?.[fmt];
|
|
38
|
+
if (!v) continue;
|
|
39
|
+
if (!Array.isArray(v)) {
|
|
40
|
+
out[fmt] = v;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const l = [...v].sort(ASC);
|
|
44
|
+
let sel = spec.widths ? [...new Set(spec.widths.map((w) => l.find((f) => f.w >= w) ?? l[l.length - 1]))] : l;
|
|
45
|
+
if (spec.max) sel = sel.filter((f) => f.w <= spec.max);
|
|
46
|
+
if (!sel.length) sel = [l[0]];
|
|
47
|
+
out[fmt] = sel.sort(ASC);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What to copy for one asset, grouped by role. Masters come from the masters package. */
|
|
53
|
+
export function chooseFiles(asset, assetId, spec) {
|
|
54
|
+
// `masters: true` alone means "I encode my own images": only the master is copied. Add formats, widths or max to
|
|
55
|
+
// get ladder files as well.
|
|
56
|
+
const wantsLadder = !spec.masters || spec.formats || spec.widths || spec.max;
|
|
57
|
+
const ladderSpec = wantsLadder ? spec : { ...spec, formats: [] };
|
|
58
|
+
const roles = { sources: chooseLadder(asset.sources, ladderSpec) };
|
|
59
|
+
if (asset.render?.tile?.rotated) roles.rotated = chooseLadder(asset.render.tile.rotated, ladderSpec);
|
|
60
|
+
if (asset.render?.frame?.atlas) roles.atlas = chooseLadder(asset.render.frame.atlas, ladderSpec);
|
|
61
|
+
if (spec.masters) roles.master = { package: 'masters', ...(asset.master ?? { path: `masters/${assetId}.webp` }) };
|
|
62
|
+
return roles;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The authored geometry of an asset without the generated ladders: what a build script needs to place it. */
|
|
66
|
+
function geometryOf(asset) {
|
|
67
|
+
const r = asset.render ?? {};
|
|
68
|
+
const out = {};
|
|
69
|
+
if (r.image) out.image = {};
|
|
70
|
+
if (r.tile) out.tile = { axis: r.tile.axis, period: r.tile.period ?? 1 };
|
|
71
|
+
if (r.frame) out.frame = { slice: r.frame.slice, ...(r.frame.slicePx ? { slicePx: r.frame.slicePx } : {}), ...(r.frame.corner ? { corner: r.frame.corner } : {}) };
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {object} o
|
|
77
|
+
* @param {{pin?: string, out?: string, assets: Array<string|object>}} o.config
|
|
78
|
+
* @param {string} o.out Destination directory.
|
|
79
|
+
* @param {string} [o.index] Local path or URL of index.json. Default: the pin's package on jsDelivr.
|
|
80
|
+
* @param {string} [o.base] Directory or URL laid out as `<base>/<set>/<path>` to read files from instead of the CDN (tests, mirrors).
|
|
81
|
+
*/
|
|
82
|
+
export async function copy({ config, out, index, base, log = () => {} }) {
|
|
83
|
+
if (!index) {
|
|
84
|
+
if (!config.pin) throw new Error('config needs a "pin" like "@ranx729/ducktape-assets@1.0.0" (or pass --index)');
|
|
85
|
+
index = `https://cdn.jsdelivr.net/npm/${config.pin}/index.json`;
|
|
86
|
+
}
|
|
87
|
+
const indexJson = JSON.parse(await load(index));
|
|
88
|
+
const resolver = createResolver({ index: indexJson, base, loadManifest: isUrl(index) ? undefined : fsManifestLoader(index) });
|
|
89
|
+
|
|
90
|
+
const plan = new Map(); // local relative path -> { url, sha256, bytes }
|
|
91
|
+
const summary = {};
|
|
92
|
+
for (const spec of config.assets.map(normalize)) {
|
|
93
|
+
const { setId, assetId, asset, manifest, entry } = await resolver.get(spec.id);
|
|
94
|
+
const roles = chooseFiles(asset, assetId, spec);
|
|
95
|
+
const local = (file) => `${setId}/${file.path}`;
|
|
96
|
+
const place = (file) => {
|
|
97
|
+
plan.set(local(file), { url: resolver.url(file, setId), sha256: file.sha256 });
|
|
98
|
+
return { w: file.w, h: file.h, path: local(file) };
|
|
99
|
+
};
|
|
100
|
+
const files = {};
|
|
101
|
+
for (const [role, v] of Object.entries(roles)) {
|
|
102
|
+
if (v.path) files[role] = place(v);
|
|
103
|
+
else files[role] = Object.fromEntries(Object.entries(v).map(([fmt, f]) => [fmt, Array.isArray(f) ? f.map(place) : place(f)]));
|
|
104
|
+
}
|
|
105
|
+
const master = asset.master ?? null;
|
|
106
|
+
summary[spec.id] = {
|
|
107
|
+
set: setId,
|
|
108
|
+
version: entry.version,
|
|
109
|
+
title: asset.title,
|
|
110
|
+
description: asset.description,
|
|
111
|
+
type: asset.type,
|
|
112
|
+
width: asset.width,
|
|
113
|
+
height: asset.height,
|
|
114
|
+
// The master may be capped below the native size; coordinates measured on it must say so.
|
|
115
|
+
...(master ? { master: { width: master.w, height: master.h } } : {}),
|
|
116
|
+
transparent: asset.transparent ?? null,
|
|
117
|
+
thumbhash: asset.thumbhash,
|
|
118
|
+
render: geometryOf(asset),
|
|
119
|
+
...(asset.components ? { components: Object.fromEntries(Object.entries(asset.components).map(([k, c]) => [k, { width: c.width, height: c.height, ...(c.description ? { description: c.description } : {}) }])) } : {}),
|
|
120
|
+
license: asset.license ?? manifest.license.artwork,
|
|
121
|
+
attribution: asset.attribution ?? manifest.attribution ?? '',
|
|
122
|
+
files,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let copied = 0, skipped = 0;
|
|
127
|
+
const queue = [...plan.entries()];
|
|
128
|
+
const worker = async () => {
|
|
129
|
+
for (let item; (item = queue.shift()); ) {
|
|
130
|
+
const [rel, { url, sha256: want }] = item;
|
|
131
|
+
const dest = join(out, rel);
|
|
132
|
+
const have = await readFile(dest).then(sha256, () => null);
|
|
133
|
+
if (have === want) {
|
|
134
|
+
skipped++;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const buf = await load(url);
|
|
138
|
+
const got = sha256(buf);
|
|
139
|
+
if (got !== want) throw new Error(`${url}: sha256 mismatch (expected ${want}, got ${got})`);
|
|
140
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
141
|
+
await writeFile(dest, buf);
|
|
142
|
+
copied++;
|
|
143
|
+
log(`copied ${rel}`);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
147
|
+
|
|
148
|
+
await mkdir(out, { recursive: true });
|
|
149
|
+
const manifest = { schema: 1, pin: config.pin ?? null, assets: summary };
|
|
150
|
+
await writeFile(join(out, 'ducktape.manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
151
|
+
return { copied, skipped, files: [...plan.keys()].sort(), manifest };
|
|
152
|
+
}
|
package/src/generate.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Generates the index package's data and docs from the published manifests of every set.
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileUrl } from './urls.mjs';
|
|
5
|
+
|
|
6
|
+
const REPO = 'https://github.com/adrian729';
|
|
7
|
+
|
|
8
|
+
export function indexEntry(manifest, { status = 'active' } = {}) {
|
|
9
|
+
const mastersCdn = manifest.cdn.replace(`${manifest.package}@`, `${manifest.mastersPackage}@`);
|
|
10
|
+
return {
|
|
11
|
+
version: manifest.version,
|
|
12
|
+
package: manifest.package,
|
|
13
|
+
parts: manifest.parts ?? [],
|
|
14
|
+
mastersPackage: manifest.mastersPackage,
|
|
15
|
+
mastersParts: manifest.mastersParts ?? [],
|
|
16
|
+
cdn: manifest.cdn,
|
|
17
|
+
mastersCdn,
|
|
18
|
+
assets: Object.keys(manifest.assets).length,
|
|
19
|
+
license: { artwork: manifest.license.artwork },
|
|
20
|
+
contentHash: manifest.contentHash,
|
|
21
|
+
status,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ladderUrls(entry, setId, ladder) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [fmt, v] of Object.entries(ladder ?? {})) {
|
|
28
|
+
out[fmt] = Array.isArray(v)
|
|
29
|
+
? Object.fromEntries([...v].sort((a, b) => a.w - b.w).map((f) => [f.w, fileUrl(entry, f, { setId })]))
|
|
30
|
+
: fileUrl(entry, v, { setId });
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DESCRIPTIVE = ['title', 'description', 'type', 'categories', 'subjects', 'colors', 'facing', 'view', 'composition', 'period', 'region', 'style', 'group', 'status', 'usage_notes', 'display_width'];
|
|
36
|
+
const MEASURED = ['width', 'height', 'transparent', 'palette', 'thumbhash', 'resolutionLimited'];
|
|
37
|
+
|
|
38
|
+
export function catalogEntry(setId, manifest, entry, assetId, asset) {
|
|
39
|
+
const e = { id: `${setId}/${assetId}`, set: setId, asset: assetId };
|
|
40
|
+
for (const k of [...DESCRIPTIVE, ...MEASURED]) if (asset[k] !== undefined && asset[k] !== null) e[k] = asset[k];
|
|
41
|
+
const r = asset.render ?? {};
|
|
42
|
+
e.render = {};
|
|
43
|
+
if (r.image) e.render.image = {};
|
|
44
|
+
if (r.tile) e.render.tile = { axis: r.tile.axis, period: r.tile.period };
|
|
45
|
+
if (r.frame) e.render.frame = { corner: r.frame.corner, slice: r.frame.slice };
|
|
46
|
+
e.license = asset.license ?? manifest.license.artwork;
|
|
47
|
+
e.attribution = asset.attribution ?? manifest.attribution ?? '';
|
|
48
|
+
e.urls = ladderUrls(entry, setId, asset.sources);
|
|
49
|
+
if (r.tile?.rotated) e.urls.rotated = ladderUrls(entry, setId, r.tile.rotated);
|
|
50
|
+
if (r.frame?.atlas) e.urls.atlas = ladderUrls(entry, setId, r.frame.atlas);
|
|
51
|
+
return e;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} o
|
|
56
|
+
* @param {Array<{setId: string, manifest: object, status?: string, repo?: string}>} o.manifests Every set; the catalog is rebuilt from scratch.
|
|
57
|
+
* @param {object} [o.previous] Previous index.json: its generatedAt is kept when the sets did not change.
|
|
58
|
+
* @param {string} [o.generatedAt]
|
|
59
|
+
* @returns {{index, catalog, sets, readme, agents}}
|
|
60
|
+
*/
|
|
61
|
+
export function generateIndex({ manifests, previous, generatedAt } = {}) {
|
|
62
|
+
const list = [...manifests].sort((a, b) => a.setId.localeCompare(b.setId));
|
|
63
|
+
const sets = {};
|
|
64
|
+
const entries = {};
|
|
65
|
+
for (const { setId, manifest, status } of list) {
|
|
66
|
+
sets[setId] = indexEntry(manifest, { status });
|
|
67
|
+
entries[setId] = manifest;
|
|
68
|
+
}
|
|
69
|
+
const unchanged = previous && JSON.stringify(previous.sets) === JSON.stringify(sets);
|
|
70
|
+
const stamp = unchanged ? previous.generatedAt : generatedAt ?? new Date().toISOString();
|
|
71
|
+
const index = { schema: 1, generatedAt: stamp, sets };
|
|
72
|
+
const assets = list.flatMap(({ setId, manifest }) =>
|
|
73
|
+
Object.entries(manifest.assets)
|
|
74
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
75
|
+
.map(([assetId, asset]) => catalogEntry(setId, manifest, sets[setId], assetId, asset)),
|
|
76
|
+
);
|
|
77
|
+
const catalog = { schema: 1, generatedAt: stamp, assets };
|
|
78
|
+
const setFiles = Object.fromEntries(list.map(({ setId, manifest }) => [setId, manifest]));
|
|
79
|
+
return { index, catalog, sets: setFiles, readme: readme(list), agents: agents(list) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cell = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
83
|
+
|
|
84
|
+
function table(list) {
|
|
85
|
+
const rows = list.map(({ setId, manifest, repo }) => {
|
|
86
|
+
const r = repo ?? `${REPO}/ducktape-assets-${setId}`;
|
|
87
|
+
return `| \`${setId}\` | ${cell(manifest.title)} | ${cell(manifest.summary)} | ${Object.keys(manifest.assets).length} | ${cell(manifest.license.artwork)} | [repo](${r}) |`;
|
|
88
|
+
});
|
|
89
|
+
return ['| Set | Title | Summary | Assets | Licence | Repo |', '| --- | --- | --- | --- | --- | --- |', ...rows].join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const FLOWS = `1. **Paste a URL.** Any HTML, CSS or Markdown references one file on jsDelivr, for example \`https://cdn.jsdelivr.net/npm/@ranx729/ducktape-assets-walters-w102@1.2.0/webp/rabbit-church-bells-512.webp\`. Only that file is transferred. \`catalog.json\` lists the exact URL of every width and format, so nothing has to be guessed.
|
|
93
|
+
2. **Pick and copy.** Keep a small \`ducktape.json\` listing the asset ids you want, with optional widths, formats and \`masters: true\`. \`npx @ranx729/ducktape-assets copy\` fetches exactly those files into the project (default \`src/assets/ducktape/\`), verifies every SHA-256 against the manifest, and writes \`ducktape.manifest.json\` beside them with type, dimensions (native and of the capped master), transparency, render geometry (tile axis, frame slice), thumbhash, licence and attribution per asset. \`masters: true\` alone copies only the lossless master (for apps that encode their own images); add \`formats\`, \`widths\` or \`max\` to copy ladder files as well. Re-running is idempotent.
|
|
94
|
+
3. **Resolve in code.** \`npm i @ranx729/ducktape-assets\` installs metadata and a resolver, zero images. \`assets.image(id, { width })\` returns \`src\`, \`srcset\`, \`sizes\`, width, height, thumbhash, licence, \`attribution\`, and \`file\` / \`files\` (path, bytes and sha256 of every referenced rung, for scripts that record or verify what they use). Bytes come from the CDN at request time, or from flow 2 when \`base\` points at the copied directory.
|
|
95
|
+
|
|
96
|
+
Build-time recipe in Node, offline, no network:
|
|
97
|
+
|
|
98
|
+
\`\`\`js
|
|
99
|
+
import { createNodeResolver } from '@ranx729/ducktape-assets/node';
|
|
100
|
+
const assets = await createNodeResolver({ formats: ['webp'] });
|
|
101
|
+
const img = await assets.image('walters-w102/rabbit-church-bells', { width: 256, dpr: 1 });
|
|
102
|
+
// img.src is the smallest rung at or above 256 px (never upscaled); img.file.sha256 and img.file.bytes are the publisher's.
|
|
103
|
+
// For an exact 2x pick ask for width 512 with dpr 1; the default dpr 3 returns the whole range for a srcset.
|
|
104
|
+
\`\`\``;
|
|
105
|
+
|
|
106
|
+
const CONFIG = `\`\`\`json
|
|
107
|
+
{
|
|
108
|
+
"pin": "@ranx729/ducktape-assets@3.4.0",
|
|
109
|
+
"out": "src/assets/ducktape",
|
|
110
|
+
"assets": [
|
|
111
|
+
"walters-w102/rabbit-church-bells",
|
|
112
|
+
{ "id": "numbered-plate/stepped-ribbon", "formats": ["avif", "webp"], "max": 1024 },
|
|
113
|
+
{ "id": "illuminated-acanthus/divider-center", "masters": true }
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
\`\`\``;
|
|
117
|
+
|
|
118
|
+
const URLS = `\`https://cdn.jsdelivr.net/npm/@ranx729/ducktape-assets-<set>@<version>/<format>/<asset>-<w>.<ext>\`
|
|
119
|
+
|
|
120
|
+
Exact-version URLs are immutable and cached for a year. Large sets are split into volume packages (\`…-<set>-part2\`); the catalog, the resolver and the copy command emit the right URL, so a consumer never notices the split.`;
|
|
121
|
+
|
|
122
|
+
function readme(list) {
|
|
123
|
+
return `# Ducktape Assets
|
|
124
|
+
|
|
125
|
+
Find an asset here, use it in under a minute. This package is metadata and a build-time resolver: it never contains an image. It is generated; do not edit it by hand.
|
|
126
|
+
|
|
127
|
+
## Three ways to use an asset
|
|
128
|
+
|
|
129
|
+
${FLOWS}
|
|
130
|
+
|
|
131
|
+
\`ducktape.json\`:
|
|
132
|
+
|
|
133
|
+
${CONFIG}
|
|
134
|
+
|
|
135
|
+
## URLs
|
|
136
|
+
|
|
137
|
+
${URLS}
|
|
138
|
+
|
|
139
|
+
## Files
|
|
140
|
+
|
|
141
|
+
- \`catalog.json\`: every asset with id, title, description, type, categories, subjects, colours, size, render capabilities, thumbhash, licence, attribution and the exact URL of each width and format.
|
|
142
|
+
- \`sets/<set>.json\`: the published manifest of each set (hashes, bytes, components, volume packages).
|
|
143
|
+
- \`index.json\`: the pinned version, packages and CDN base of each set.
|
|
144
|
+
|
|
145
|
+
## Sets
|
|
146
|
+
|
|
147
|
+
${table(list)}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function agents(list) {
|
|
152
|
+
return `# Ducktape Assets for agents
|
|
153
|
+
|
|
154
|
+
Generated. Use these rules; do not edit this file by hand.
|
|
155
|
+
|
|
156
|
+
## Finding an asset
|
|
157
|
+
|
|
158
|
+
- Search \`catalog.json\` (one grep finds an asset and its URL). Ids are \`<set>/<asset>\`.
|
|
159
|
+
- Read \`sets/<set>.json\` only for what the catalog leaves out (hashes, bytes, components, parts).
|
|
160
|
+
|
|
161
|
+
## Using an asset
|
|
162
|
+
|
|
163
|
+
${FLOWS}
|
|
164
|
+
|
|
165
|
+
Prefer flow 2 for anything that must survive unattended: copied files do not depend on any host.
|
|
166
|
+
|
|
167
|
+
\`ducktape.json\`:
|
|
168
|
+
|
|
169
|
+
${CONFIG}
|
|
170
|
+
|
|
171
|
+
## URLs
|
|
172
|
+
|
|
173
|
+
${URLS}
|
|
174
|
+
|
|
175
|
+
## Rules
|
|
176
|
+
|
|
177
|
+
- Honour the licence block: show the \`attribution\` line when the asset's licence requires it.
|
|
178
|
+
- Never use \`@latest\` or range URLs; always an exact version from \`index.json\` or \`catalog.json\`.
|
|
179
|
+
- Never put a master on a page. Masters are for re-encoding in a build.
|
|
180
|
+
- Images are decorative by default (\`alt=""\`); use \`description\` when the image carries meaning.
|
|
181
|
+
- Always set the \`width\` and \`height\` attributes; the resolver returns them.
|
|
182
|
+
|
|
183
|
+
## Sets
|
|
184
|
+
|
|
185
|
+
${table(list)}
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Write the generated files of a generateIndex() result into `dir`. */
|
|
190
|
+
export async function writeIndex(dir, { index, catalog, sets, readme, agents }) {
|
|
191
|
+
const json = (v) => JSON.stringify(v, null, 2) + '\n';
|
|
192
|
+
await mkdir(join(dir, 'sets'), { recursive: true });
|
|
193
|
+
await Promise.all([
|
|
194
|
+
writeFile(join(dir, 'index.json'), json(index)),
|
|
195
|
+
writeFile(join(dir, 'catalog.json'), json(catalog)),
|
|
196
|
+
writeFile(join(dir, 'README.md'), readme),
|
|
197
|
+
writeFile(join(dir, 'AGENTS.md'), agents),
|
|
198
|
+
...Object.entries(sets).map(([id, m]) => writeFile(join(dir, 'sets', `${id}.json`), json(m))),
|
|
199
|
+
]);
|
|
200
|
+
}
|
package/src/node.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Node-only helpers. Kept apart so the resolver stays browser-safe.
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createResolver } from './resolver.mjs';
|
|
6
|
+
|
|
7
|
+
/** loadManifest for createResolver: reads `sets/<set>.json` beside a local index.json. */
|
|
8
|
+
export function fsManifestLoader(indexPath) {
|
|
9
|
+
const dir = dirname(indexPath);
|
|
10
|
+
return async (setId) => JSON.parse(await readFile(join(dir, 'sets', `${setId}.json`), 'utf8'));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A resolver over the data shipped in this package (or a local copy of it), with no network at all:
|
|
15
|
+
* the index and every set manifest are read from disk, so build scripts work offline and get the same
|
|
16
|
+
* hashes and bytes the publisher recorded. Options are those of createResolver plus `indexPath`.
|
|
17
|
+
*/
|
|
18
|
+
export async function createNodeResolver({ indexPath = fileURLToPath(new URL('../index.json', import.meta.url)), ...options } = {}) {
|
|
19
|
+
const index = JSON.parse(await readFile(indexPath, 'utf8'));
|
|
20
|
+
return createResolver({ index, loadManifest: fsManifestLoader(indexPath), ...options });
|
|
21
|
+
}
|
package/src/resolver.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Build-time resolver. Pure functions over index.json and per-set manifests; no DOM, no dependencies.
|
|
2
|
+
import { fileUrl, packageCdn } from './urls.mjs';
|
|
3
|
+
import { selectRungs, pickByDimension, dprList, maxOf } from './rungs.mjs';
|
|
4
|
+
import { thumbHashToDataUrl } from './thumbhash.mjs';
|
|
5
|
+
|
|
6
|
+
const MIME = { avif: 'image/avif', webp: 'image/webp', png: 'image/png', jpeg: 'image/jpeg', svg: 'image/svg+xml' };
|
|
7
|
+
const mime = (fmt) => MIME[fmt] ?? `image/${fmt}`;
|
|
8
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
9
|
+
const cssUrl = (u) => `url("${u.replace(/"/g, '%22')}")`;
|
|
10
|
+
const attrs = (o) =>
|
|
11
|
+
Object.entries(o)
|
|
12
|
+
.filter(([, v]) => v !== false && v != null)
|
|
13
|
+
.map(([k, v]) => (v === true ? k : `${k}="${esc(v)}"`))
|
|
14
|
+
.join(' ');
|
|
15
|
+
|
|
16
|
+
async function getJson(url) {
|
|
17
|
+
const res = await fetch(url);
|
|
18
|
+
if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
|
|
19
|
+
return res.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {object|string} [options.index] index.json object, or its URL. Default: the index.json shipped in this package.
|
|
25
|
+
* @param {string} [options.base] Self-hosted tree (`<base>/<set>/<path>`), e.g. the output of the copy command.
|
|
26
|
+
* @param {string[]} [options.formats] Preference order. The last one present is the <img> fallback.
|
|
27
|
+
* @param {Record<string,string>} [options.pin] Per-set version override, { setId: '1.2.0' }.
|
|
28
|
+
* @param {number|number[]} [options.dpr] Highest density of interest (default 3).
|
|
29
|
+
* @param {(setId: string, ctx: {entry: object, cdn: string}) => Promise<object>} [options.loadManifest]
|
|
30
|
+
* Default fetches `<cdn>manifest.json`. In Node, `fsManifestLoader` from `@ranx729/ducktape-assets/node` reads sets/<set>.json.
|
|
31
|
+
*/
|
|
32
|
+
export function createResolver({ index, base, formats = ['avif', 'webp'], pin = {}, dpr, loadManifest } = {}) {
|
|
33
|
+
let idx = index && typeof index === 'object' ? index : null;
|
|
34
|
+
let idxPromise;
|
|
35
|
+
const manifests = new Map();
|
|
36
|
+
|
|
37
|
+
function getIndex() {
|
|
38
|
+
idxPromise ??= (async () => {
|
|
39
|
+
if (idx) return idx;
|
|
40
|
+
idx =
|
|
41
|
+
typeof index === 'string'
|
|
42
|
+
? await getJson(index)
|
|
43
|
+
: (await import('../index.json', { with: { type: 'json' } })).default;
|
|
44
|
+
return idx;
|
|
45
|
+
})();
|
|
46
|
+
return idxPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const splitId = (id) => {
|
|
50
|
+
const i = id.indexOf('/');
|
|
51
|
+
if (i < 1 || i === id.length - 1) throw new Error(`asset id must be "<set>/<asset>", got "${id}"`);
|
|
52
|
+
return [id.slice(0, i), id.slice(i + 1)];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
async function entryOf(setId) {
|
|
56
|
+
const entry = (await getIndex()).sets[setId];
|
|
57
|
+
if (!entry) throw new Error(`unknown set "${setId}"`);
|
|
58
|
+
return entry;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const urlOf = (entry, file, setId) => fileUrl(entry, file, { setId, base, version: pin[setId] });
|
|
62
|
+
|
|
63
|
+
function manifestOf(setId) {
|
|
64
|
+
if (!manifests.has(setId)) {
|
|
65
|
+
manifests.set(
|
|
66
|
+
setId,
|
|
67
|
+
(async () => {
|
|
68
|
+
const entry = await entryOf(setId);
|
|
69
|
+
const cdn = packageCdn(entry, undefined, pin[setId] ?? entry.version);
|
|
70
|
+
const m = await (loadManifest ? loadManifest(setId, { entry, cdn }) : getJson(`${cdn}manifest.json`));
|
|
71
|
+
return m;
|
|
72
|
+
})(),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return manifests.get(setId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everything known about one asset: { id, setId, assetId, entry, manifest, asset }. */
|
|
79
|
+
async function get(id) {
|
|
80
|
+
const [setId, assetId] = splitId(id);
|
|
81
|
+
const entry = await entryOf(setId);
|
|
82
|
+
const manifest = await manifestOf(setId);
|
|
83
|
+
const asset = manifest.assets[assetId];
|
|
84
|
+
if (!asset) throw new Error(`unknown asset "${id}"`);
|
|
85
|
+
return { id, setId, assetId, entry, manifest, asset };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Synchronous; the index must already be loaded (any awaited call loads it) unless `base` is set. */
|
|
89
|
+
function url(file, setId) {
|
|
90
|
+
if (!setId) throw new Error('url(file, setId): setId is required');
|
|
91
|
+
if (!base && !idx) throw new Error('index not loaded yet; await any resolver call first or pass index');
|
|
92
|
+
return urlOf(idx?.sets[setId], file, setId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const licenseOf = ({ asset, manifest }) => ({ ...manifest.license, artwork: asset.license ?? manifest.license.artwork });
|
|
96
|
+
const attributionOf = ({ asset, manifest }) => asset.attribution ?? manifest.attribution ?? '';
|
|
97
|
+
|
|
98
|
+
async function image(id, { width, sizes, dpr: d, placeholder = true } = {}) {
|
|
99
|
+
const ctx = await get(id);
|
|
100
|
+
const { setId, entry, asset } = ctx;
|
|
101
|
+
const maxDpr = maxOf(d ?? dpr, 3);
|
|
102
|
+
const sources = [];
|
|
103
|
+
const files = [];
|
|
104
|
+
const fileOf = (fmt, r) => ({ format: fmt, url: urlOf(entry, r, setId), path: r.path, width: r.w, height: r.h, bytes: r.bytes, sha256: r.sha256, ...(r.package ? { package: r.package } : {}) });
|
|
105
|
+
let fallback;
|
|
106
|
+
let fallbackFmt;
|
|
107
|
+
for (const fmt of formats) {
|
|
108
|
+
const ladder = asset.sources?.[fmt];
|
|
109
|
+
if (!Array.isArray(ladder) || !ladder.length) continue;
|
|
110
|
+
const sel = selectRungs(ladder, width, { maxDpr });
|
|
111
|
+
sources.push({ type: mime(fmt), srcset: sel.rungs.map((r) => `${urlOf(entry, r, setId)} ${r.w}w`).join(', ') });
|
|
112
|
+
files.push(...sel.rungs.map((r) => fileOf(fmt, r)));
|
|
113
|
+
fallback = sel;
|
|
114
|
+
fallbackFmt = fmt;
|
|
115
|
+
}
|
|
116
|
+
if (!fallback) throw new Error(`${id}: none of the formats ${formats.join(', ')} is available`);
|
|
117
|
+
const out = {
|
|
118
|
+
src: urlOf(entry, fallback.src, setId),
|
|
119
|
+
srcset: sources[sources.length - 1].srcset,
|
|
120
|
+
sources,
|
|
121
|
+
// The chosen files with their bytes and sha256, for build scripts that record or verify what they reference.
|
|
122
|
+
// `file` is the one behind `src`; `files` lists every rung of every format in the srcsets.
|
|
123
|
+
file: fileOf(fallbackFmt, fallback.src),
|
|
124
|
+
files,
|
|
125
|
+
sizes: sizes ?? (width ? `${width}px` : '100vw'),
|
|
126
|
+
width: asset.width,
|
|
127
|
+
height: asset.height,
|
|
128
|
+
thumbhash: asset.thumbhash,
|
|
129
|
+
alt: '',
|
|
130
|
+
description: asset.description,
|
|
131
|
+
license: licenseOf(ctx),
|
|
132
|
+
attribution: attributionOf(ctx),
|
|
133
|
+
resolutionLimited: Boolean(width && asset.width < width),
|
|
134
|
+
};
|
|
135
|
+
if (placeholder && asset.thumbhash) out.placeholderDataUrl = thumbHashToDataUrl(asset.thumbhash);
|
|
136
|
+
if (asset.sources?.svg) out.svg = urlOf(entry, asset.sources.svg, setId);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `<picture>` markup. `extra` is added to the <img>. */
|
|
141
|
+
function picture(img, extra = {}) {
|
|
142
|
+
const lead = img.sources.slice(0, -1);
|
|
143
|
+
const style = [img.placeholderDataUrl && `background: url(${img.placeholderDataUrl}) center / cover`, extra.style]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join('; ');
|
|
146
|
+
const { style: _s, ...rest } = extra;
|
|
147
|
+
const tag = `<img ${attrs({ src: img.src, srcset: img.srcset, sizes: img.sizes, width: img.width, height: img.height, alt: img.alt, ...rest, style: style || null })}>`;
|
|
148
|
+
if (!lead.length) return tag;
|
|
149
|
+
const srcs = lead.map((s) => ` <source ${attrs({ type: s.type, srcset: s.srcset, sizes: img.sizes })}>`);
|
|
150
|
+
return `<picture>\n${srcs.join('\n')}\n ${tag}\n</picture>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `<link rel="preload">` for an above-the-fold image, in the most preferred format. */
|
|
154
|
+
function preload(img) {
|
|
155
|
+
const s = img.sources[0];
|
|
156
|
+
return `<link ${attrs({ rel: 'preload', as: 'image', type: s.type, href: img.src, imagesrcset: s.srcset, imagesizes: img.sizes, fetchpriority: 'high' })}>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** image-set() candidates over the preferred formats, one entry per (format, density), deduplicated by URL. */
|
|
160
|
+
function imageSet(entry, setId, ladder, key, thickness, dprs, geom) {
|
|
161
|
+
const parts = [];
|
|
162
|
+
let ref;
|
|
163
|
+
for (const fmt of formats) {
|
|
164
|
+
const files = ladder?.[fmt];
|
|
165
|
+
if (!Array.isArray(files) || !files.length) continue;
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
for (const x of dprs) {
|
|
168
|
+
const f = pickByDimension(files, key, geom(x));
|
|
169
|
+
ref ??= f;
|
|
170
|
+
const u = urlOf(entry, f, setId);
|
|
171
|
+
if (seen.has(u)) continue;
|
|
172
|
+
seen.add(u);
|
|
173
|
+
parts.push(`${cssUrl(u)} type("${mime(fmt)}") ${x}x`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { set: parts.length ? `image-set(${parts.join(', ')})` : null, ref };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** CSS declarations for a repeating border (`render.tile`). Prefers the SVG of the chosen ladder. */
|
|
180
|
+
async function tile(id, { axis, thickness, dpr: d } = {}) {
|
|
181
|
+
const { setId, entry, asset } = await get(id);
|
|
182
|
+
const t = asset.render?.tile;
|
|
183
|
+
if (!t) throw new Error(`${id} has no render.tile`);
|
|
184
|
+
axis ??= t.axis;
|
|
185
|
+
const ladder = axis === t.axis ? asset.sources : t.rotated;
|
|
186
|
+
const key = axis === 'x' ? 'h' : 'w'; // thickness dimension in the displayed orientation
|
|
187
|
+
thickness ??= t.axis === 'x' ? asset.height : asset.width;
|
|
188
|
+
let image, ref;
|
|
189
|
+
if (ladder?.svg) {
|
|
190
|
+
ref = ladder.svg;
|
|
191
|
+
image = cssUrl(urlOf(entry, ref, setId));
|
|
192
|
+
} else {
|
|
193
|
+
({ set: image, ref } = imageSet(entry, setId, ladder, key, thickness, dprList(d ?? dpr, [1, 2]), (x) => thickness * x));
|
|
194
|
+
}
|
|
195
|
+
if (!image) throw new Error(`${id}: no ${axis}-axis ladder in formats ${formats.join(', ')}`);
|
|
196
|
+
const len = Math.round(thickness * (axis === 'x' ? ref.w / ref.h : ref.h / ref.w) * 100) / 100;
|
|
197
|
+
return [
|
|
198
|
+
`background-image: ${image};`,
|
|
199
|
+
`background-repeat: ${axis === 'x' ? 'round no-repeat' : 'no-repeat round'};`,
|
|
200
|
+
`background-size: ${axis === 'x' ? `${len}px ${thickness}px` : `${thickness}px ${len}px`};`,
|
|
201
|
+
].join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `border-image` rule for a frame (`render.frame`): the atlas rung whose slice region covers `thickness` at each density. */
|
|
205
|
+
async function frame(id, { thickness, dpr: d } = {}) {
|
|
206
|
+
const { setId, entry, asset } = await get(id);
|
|
207
|
+
const f = asset.render?.frame;
|
|
208
|
+
if (!f) throw new Error(`${id} has no render.frame`);
|
|
209
|
+
// Composed atlases record one percentage; ready-made frames record four (top right bottom left) because a uniform
|
|
210
|
+
// pixel slice is a different share of the width and of the height. The CSS value and the coverage maths follow.
|
|
211
|
+
const slice = f.slice;
|
|
212
|
+
const sliceCss = typeof slice === 'number' ? `${slice}%` : `${slice.top}% ${slice.right}% ${slice.bottom}% ${slice.left}%`;
|
|
213
|
+
const sliceW = typeof slice === 'number' ? slice : slice.left; // share of the atlas width that must cover `thickness`
|
|
214
|
+
const atlas = f.atlas;
|
|
215
|
+
const dprs = dprList(d ?? dpr, [1, 2, 3]);
|
|
216
|
+
const smallest = Object.values(atlas).find(Array.isArray)?.[0];
|
|
217
|
+
thickness ??= smallest ? Math.round((smallest.w * sliceW) / 100) : 16;
|
|
218
|
+
// the slice region (sliceW% of the atlas width) must map onto `thickness` css px at density x
|
|
219
|
+
const need = (x) => (thickness * x * 100) / sliceW;
|
|
220
|
+
let value;
|
|
221
|
+
if (atlas.svg) value = cssUrl(urlOf(entry, atlas.svg, setId));
|
|
222
|
+
else value = imageSet(entry, setId, atlas, 'w', thickness, dprs, need).set;
|
|
223
|
+
if (!value) throw new Error(`${id}: no atlas in formats ${formats.join(', ')}`);
|
|
224
|
+
const fmt = formats.find((x) => atlas[x]?.length);
|
|
225
|
+
const atlasSrcset = fmt
|
|
226
|
+
? [...new Set(dprs.map((x) => pickByDimension(atlas[fmt], 'w', need(x))))]
|
|
227
|
+
.map((r) => `${urlOf(entry, r, setId)} ${r.w}w`)
|
|
228
|
+
.join(', ')
|
|
229
|
+
: '';
|
|
230
|
+
const css = [`border-style: solid;`, `border-width: ${thickness}px;`, `border-image: ${value} ${sliceCss} round;`].join('\n');
|
|
231
|
+
return { css, slice, sliceCss, atlasSrcset };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Paths of the files the copy command writes for an asset (all rungs), for flows that import local files. */
|
|
235
|
+
async function local(id, { dir = '' } = {}) {
|
|
236
|
+
const { setId, assetId, asset } = await get(id);
|
|
237
|
+
const at = (file) => [dir.replace(/\/+$/, ''), setId, file.path].filter(Boolean).join('/');
|
|
238
|
+
const ladderPaths = (l) =>
|
|
239
|
+
l && Object.fromEntries(Object.entries(l).map(([k, v]) => [k, Array.isArray(v) ? v.map((f) => ({ w: f.w, h: f.h, path: at(f) })) : { w: v.w, h: v.h, path: at(v) }]));
|
|
240
|
+
const files = { sources: ladderPaths(asset.sources) };
|
|
241
|
+
if (asset.render?.tile?.rotated) files.rotated = ladderPaths(asset.render.tile.rotated);
|
|
242
|
+
if (asset.render?.frame?.atlas) files.atlas = ladderPaths(asset.render.frame.atlas);
|
|
243
|
+
files.master = at(asset.master ?? { path: `masters/${assetId}.webp` });
|
|
244
|
+
return { id, width: asset.width, height: asset.height, thumbhash: asset.thumbhash, files };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { image, picture, preload, tile, frame, local, url, get, index: getIndex, manifest: manifestOf };
|
|
248
|
+
}
|
package/src/rungs.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Rung selection. Pure functions over ladders: arrays of { w, h, path, ... }.
|
|
2
|
+
|
|
3
|
+
const asc = (key) => (a, b) => a[key] - b[key];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* From the smallest rung >= width up to the smallest rung >= width * maxDpr, never upscaling
|
|
7
|
+
* (a missing rung falls back to the top one). `src` is the smallest rung >= width * srcDpr.
|
|
8
|
+
* Without a width the whole ladder is returned and `src` is the largest rung up to 1024.
|
|
9
|
+
*/
|
|
10
|
+
export function selectRungs(ladder, width, { maxDpr = 3, srcDpr = 2 } = {}) {
|
|
11
|
+
const l = [...ladder].sort(asc('w'));
|
|
12
|
+
const top = l[l.length - 1];
|
|
13
|
+
if (!width) return { rungs: l, src: [...l].reverse().find((r) => r.w <= 1024) ?? l[0] };
|
|
14
|
+
const lo = l.find((r) => r.w >= width) ?? top;
|
|
15
|
+
const hi = l.find((r) => r.w >= width * maxDpr) ?? top;
|
|
16
|
+
const rungs = l.filter((r) => r.w >= lo.w && r.w <= hi.w);
|
|
17
|
+
const src = l.find((r) => r.w >= width * Math.min(srcDpr, maxDpr)) ?? top;
|
|
18
|
+
return { rungs, src };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Smallest file whose `key` dimension ('w' or 'h') covers `need` (half a pixel of tolerance), else the largest. */
|
|
22
|
+
export function pickByDimension(files, key, need) {
|
|
23
|
+
const l = [...files].sort(asc(key));
|
|
24
|
+
return l.find((f) => f[key] >= need - 0.5) ?? l[l.length - 1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normalise a dpr option: a number n means 1..n, an array is used as given. */
|
|
28
|
+
export function dprList(d, fallback) {
|
|
29
|
+
const v = d ?? fallback;
|
|
30
|
+
if (Array.isArray(v)) return [...new Set(v)].sort((a, b) => a - b);
|
|
31
|
+
return Array.from({ length: Math.max(1, Math.ceil(v)) }, (_, i) => i + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const maxOf = (d, fallback) => Math.max(...dprList(d, fallback));
|