@ranx729/ducktape-assets 1.0.0 → 1.2.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 +12 -2
- package/README.md +12 -2
- package/catalog.json +1697 -1663
- package/index.json +11 -11
- package/package.json +3 -3
- package/sets/illuminated-acanthus.json +242 -86
- package/sets/medieval-cutouts.json +6 -5
- package/sets/walters-w102.json +5 -5
- package/src/copy.mjs +24 -3
- package/src/generate.mjs +12 -2
- package/src/node.mjs +12 -0
- package/src/resolver.mjs +18 -5
package/src/generate.mjs
CHANGED
|
@@ -90,8 +90,18 @@ function table(list) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
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 dimensions, thumbhash, licence and attribution per asset. 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 and
|
|
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
|
+
\`\`\``;
|
|
95
105
|
|
|
96
106
|
const CONFIG = `\`\`\`json
|
|
97
107
|
{
|
package/src/node.mjs
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
// Node-only helpers. Kept apart so the resolver stays browser-safe.
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createResolver } from './resolver.mjs';
|
|
4
6
|
|
|
5
7
|
/** loadManifest for createResolver: reads `sets/<set>.json` beside a local index.json. */
|
|
6
8
|
export function fsManifestLoader(indexPath) {
|
|
7
9
|
const dir = dirname(indexPath);
|
|
8
10
|
return async (setId) => JSON.parse(await readFile(join(dir, 'sets', `${setId}.json`), 'utf8'));
|
|
9
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
CHANGED
|
@@ -100,19 +100,28 @@ export function createResolver({ index, base, formats = ['avif', 'webp'], pin =
|
|
|
100
100
|
const { setId, entry, asset } = ctx;
|
|
101
101
|
const maxDpr = maxOf(d ?? dpr, 3);
|
|
102
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 } : {}) });
|
|
103
105
|
let fallback;
|
|
106
|
+
let fallbackFmt;
|
|
104
107
|
for (const fmt of formats) {
|
|
105
108
|
const ladder = asset.sources?.[fmt];
|
|
106
109
|
if (!Array.isArray(ladder) || !ladder.length) continue;
|
|
107
110
|
const sel = selectRungs(ladder, width, { maxDpr });
|
|
108
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)));
|
|
109
113
|
fallback = sel;
|
|
114
|
+
fallbackFmt = fmt;
|
|
110
115
|
}
|
|
111
116
|
if (!fallback) throw new Error(`${id}: none of the formats ${formats.join(', ')} is available`);
|
|
112
117
|
const out = {
|
|
113
118
|
src: urlOf(entry, fallback.src, setId),
|
|
114
119
|
srcset: sources[sources.length - 1].srcset,
|
|
115
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,
|
|
116
125
|
sizes: sizes ?? (width ? `${width}px` : '100vw'),
|
|
117
126
|
width: asset.width,
|
|
118
127
|
height: asset.height,
|
|
@@ -197,13 +206,17 @@ export function createResolver({ index, base, formats = ['avif', 'webp'], pin =
|
|
|
197
206
|
const { setId, entry, asset } = await get(id);
|
|
198
207
|
const f = asset.render?.frame;
|
|
199
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.
|
|
200
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`
|
|
201
214
|
const atlas = f.atlas;
|
|
202
215
|
const dprs = dprList(d ?? dpr, [1, 2, 3]);
|
|
203
216
|
const smallest = Object.values(atlas).find(Array.isArray)?.[0];
|
|
204
|
-
thickness ??= smallest ? Math.round((smallest.w *
|
|
205
|
-
// the slice region (
|
|
206
|
-
const need = (x) => (thickness * x * 100) /
|
|
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;
|
|
207
220
|
let value;
|
|
208
221
|
if (atlas.svg) value = cssUrl(urlOf(entry, atlas.svg, setId));
|
|
209
222
|
else value = imageSet(entry, setId, atlas, 'w', thickness, dprs, need).set;
|
|
@@ -214,8 +227,8 @@ export function createResolver({ index, base, formats = ['avif', 'webp'], pin =
|
|
|
214
227
|
.map((r) => `${urlOf(entry, r, setId)} ${r.w}w`)
|
|
215
228
|
.join(', ')
|
|
216
229
|
: '';
|
|
217
|
-
const css = [`border-style: solid;`, `border-width: ${thickness}px;`, `border-image: ${value} ${
|
|
218
|
-
return { css, slice, atlasSrcset };
|
|
230
|
+
const css = [`border-style: solid;`, `border-width: ${thickness}px;`, `border-image: ${value} ${sliceCss} round;`].join('\n');
|
|
231
|
+
return { css, slice, sliceCss, atlasSrcset };
|
|
219
232
|
}
|
|
220
233
|
|
|
221
234
|
/** Paths of the files the copy command writes for an asset (all rungs), for flows that import local files. */
|