@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/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 licence. Bytes come from the CDN at request time, or from flow 2 when \`base\` points at the copied directory.`;
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 * slice) / 100) : 16;
205
- // the slice region (slice% of the atlas) must map onto `thickness` css px at density x
206
- const need = (x) => (thickness * x * 100) / slice;
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} ${slice}% round;`].join('\n');
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. */