@usenavii/core 0.5.0 → 0.7.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/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { b as AvatarOptions, c as AvatarSpec, d as BodyShapeId, E as EyeStyleId, M as MouthStyleId, a as AntennaStyleId, A as AccessoryId, B as BackgroundId, T as TopperId, O as OutfitId, P as Palette } from './types-CF0rfKly.cjs';
2
- export { S as StyleHint } from './types-CF0rfKly.cjs';
1
+ import { b as AvatarOptions, c as AvatarSpec, d as BodyShapeId, E as EyeStyleId, e as MouthStyleId, a as AntennaStyleId, A as AccessoryId, B as BackgroundId, T as TopperId, O as OutfitId, P as Palette } from './types-BtX6LIyn.cjs';
2
+ export { M as MoodId, S as StyleHint } from './types-BtX6LIyn.cjs';
3
3
 
4
4
  /**
5
5
  * Seed → stable PRNG stream.
@@ -82,6 +82,28 @@ declare function renderGroup(seeds: string[], options?: GroupOptions): string;
82
82
  * it with `createdAt` makes the result globally unique while remaining
83
83
  * stable across renders (assuming `createdAt` is set once at signup).
84
84
  */
85
+ /**
86
+ * Normalize an email the same way Gravatar does — trim + lowercase, NFC.
87
+ * Exported so callers can reproduce the canonical form before hashing.
88
+ */
89
+ declare function normalizeEmail(email: string): string;
90
+ /**
91
+ * Turn an email into a stable, opaque seed using Gravatar's scheme:
92
+ * `sha256(trim(lowercase(email)))` → lowercase hex.
93
+ *
94
+ * Why: passing raw emails as seeds leaks them through URLs (server logs,
95
+ * Referer headers, browser history, CDN cache keys, analytics). The hash
96
+ * is stable across systems that normalize the same way, so two products
97
+ * looking up the same person get the same avatar.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const s = seedFromEmail(user.email);
102
+ * createAvatar(s); // safe to log
103
+ * // or hit the API: `/avatar/${s}.svg` // no plaintext email on the wire
104
+ * ```
105
+ */
106
+ declare function seedFromEmail(email: string): string;
85
107
  interface SeedFields {
86
108
  /** Stable primary key (database id, UUID, OAuth sub). Best choice. */
87
109
  id?: string | number | null | undefined;
@@ -92,6 +114,18 @@ interface SeedFields {
92
114
  /** Account creation time. Combined with `name` to bake uniqueness in at signup. */
93
115
  createdAt?: string | number | Date | null | undefined;
94
116
  }
117
+ interface SeedOptions {
118
+ /**
119
+ * When the email branch is used, hash the email instead of returning it
120
+ * raw. Hashing keeps the seed stable but stops the address from leaking
121
+ * into URLs, server logs, and Referer headers. Default `true` from v1.
122
+ *
123
+ * Set to `false` to opt back into the legacy plaintext-email behavior —
124
+ * useful for migrations where existing avatars are keyed off the raw
125
+ * email and you don't want every user's face to change.
126
+ */
127
+ hashEmail?: boolean;
128
+ }
95
129
  /**
96
130
  * Compose a stable seed string from the most unique field available.
97
131
  *
@@ -107,9 +141,27 @@ interface SeedFields {
107
141
  * // → "Alice|1700000000000"
108
142
  * ```
109
143
  *
144
+ * @example Opt out of email hashing (legacy behavior):
145
+ * ```ts
146
+ * seed({ email: 'a@b.c' }, { hashEmail: false });
147
+ * // → "a@b.c" — avoid; only for migrating off the old default.
148
+ * ```
149
+ *
110
150
  * @throws if no usable field is provided.
111
151
  */
112
- declare function seed(fields: SeedFields): string;
152
+ declare function seed(fields: SeedFields, options?: SeedOptions): string;
153
+
154
+ /**
155
+ * Sync SHA-256 (FIPS 180-4) → lowercase hex.
156
+ *
157
+ * Used by `seedFromEmail()` to match Gravatar's seed format
158
+ * (sha256 of trimmed, lowercased email). Sync API on purpose —
159
+ * a seed helper that returns a Promise is a footgun in render code.
160
+ *
161
+ * ~50 lines, no deps, zero allocations after init. Not for crypto
162
+ * use beyond opaque-identifier hashing.
163
+ */
164
+ declare function sha256Hex(input: string): string;
113
165
 
114
166
  /**
115
167
  * Direct construction of an avatar from explicit part choices — no seed.
@@ -382,7 +434,8 @@ declare const Navii: {
382
434
  readonly select: typeof selectAvatar;
383
435
  readonly group: typeof renderGroup;
384
436
  readonly seed: typeof seed;
437
+ readonly seedFromEmail: typeof seedFromEmail;
385
438
  readonly build: typeof build;
386
439
  };
387
440
 
388
- export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, TopperId, build, createAvatar, createRng, cyrb53, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, selectAvatar };
441
+ export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, type SeedOptions, TopperId, build, createAvatar, createRng, cyrb53, normalizeEmail, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, seedFromEmail, selectAvatar, sha256Hex };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { b as AvatarOptions, c as AvatarSpec, d as BodyShapeId, E as EyeStyleId, M as MouthStyleId, a as AntennaStyleId, A as AccessoryId, B as BackgroundId, T as TopperId, O as OutfitId, P as Palette } from './types-CF0rfKly.js';
2
- export { S as StyleHint } from './types-CF0rfKly.js';
1
+ import { b as AvatarOptions, c as AvatarSpec, d as BodyShapeId, E as EyeStyleId, e as MouthStyleId, a as AntennaStyleId, A as AccessoryId, B as BackgroundId, T as TopperId, O as OutfitId, P as Palette } from './types-BtX6LIyn.js';
2
+ export { M as MoodId, S as StyleHint } from './types-BtX6LIyn.js';
3
3
 
4
4
  /**
5
5
  * Seed → stable PRNG stream.
@@ -82,6 +82,28 @@ declare function renderGroup(seeds: string[], options?: GroupOptions): string;
82
82
  * it with `createdAt` makes the result globally unique while remaining
83
83
  * stable across renders (assuming `createdAt` is set once at signup).
84
84
  */
85
+ /**
86
+ * Normalize an email the same way Gravatar does — trim + lowercase, NFC.
87
+ * Exported so callers can reproduce the canonical form before hashing.
88
+ */
89
+ declare function normalizeEmail(email: string): string;
90
+ /**
91
+ * Turn an email into a stable, opaque seed using Gravatar's scheme:
92
+ * `sha256(trim(lowercase(email)))` → lowercase hex.
93
+ *
94
+ * Why: passing raw emails as seeds leaks them through URLs (server logs,
95
+ * Referer headers, browser history, CDN cache keys, analytics). The hash
96
+ * is stable across systems that normalize the same way, so two products
97
+ * looking up the same person get the same avatar.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * const s = seedFromEmail(user.email);
102
+ * createAvatar(s); // safe to log
103
+ * // or hit the API: `/avatar/${s}.svg` // no plaintext email on the wire
104
+ * ```
105
+ */
106
+ declare function seedFromEmail(email: string): string;
85
107
  interface SeedFields {
86
108
  /** Stable primary key (database id, UUID, OAuth sub). Best choice. */
87
109
  id?: string | number | null | undefined;
@@ -92,6 +114,18 @@ interface SeedFields {
92
114
  /** Account creation time. Combined with `name` to bake uniqueness in at signup. */
93
115
  createdAt?: string | number | Date | null | undefined;
94
116
  }
117
+ interface SeedOptions {
118
+ /**
119
+ * When the email branch is used, hash the email instead of returning it
120
+ * raw. Hashing keeps the seed stable but stops the address from leaking
121
+ * into URLs, server logs, and Referer headers. Default `true` from v1.
122
+ *
123
+ * Set to `false` to opt back into the legacy plaintext-email behavior —
124
+ * useful for migrations where existing avatars are keyed off the raw
125
+ * email and you don't want every user's face to change.
126
+ */
127
+ hashEmail?: boolean;
128
+ }
95
129
  /**
96
130
  * Compose a stable seed string from the most unique field available.
97
131
  *
@@ -107,9 +141,27 @@ interface SeedFields {
107
141
  * // → "Alice|1700000000000"
108
142
  * ```
109
143
  *
144
+ * @example Opt out of email hashing (legacy behavior):
145
+ * ```ts
146
+ * seed({ email: 'a@b.c' }, { hashEmail: false });
147
+ * // → "a@b.c" — avoid; only for migrating off the old default.
148
+ * ```
149
+ *
110
150
  * @throws if no usable field is provided.
111
151
  */
112
- declare function seed(fields: SeedFields): string;
152
+ declare function seed(fields: SeedFields, options?: SeedOptions): string;
153
+
154
+ /**
155
+ * Sync SHA-256 (FIPS 180-4) → lowercase hex.
156
+ *
157
+ * Used by `seedFromEmail()` to match Gravatar's seed format
158
+ * (sha256 of trimmed, lowercased email). Sync API on purpose —
159
+ * a seed helper that returns a Promise is a footgun in render code.
160
+ *
161
+ * ~50 lines, no deps, zero allocations after init. Not for crypto
162
+ * use beyond opaque-identifier hashing.
163
+ */
164
+ declare function sha256Hex(input: string): string;
113
165
 
114
166
  /**
115
167
  * Direct construction of an avatar from explicit part choices — no seed.
@@ -382,7 +434,8 @@ declare const Navii: {
382
434
  readonly select: typeof selectAvatar;
383
435
  readonly group: typeof renderGroup;
384
436
  readonly seed: typeof seed;
437
+ readonly seedFromEmail: typeof seedFromEmail;
385
438
  readonly build: typeof build;
386
439
  };
387
440
 
388
- export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, TopperId, build, createAvatar, createRng, cyrb53, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, selectAvatar };
441
+ export { AccessoryId, AntennaStyleId, AvatarOptions, AvatarSpec, BUILT_IN_PACKS, BackgroundId, BodyShapeId, type BuildSpec, EyeStyleId, type GroupOptions, MouthStyleId, Navii, OutfitId, PACK_REGISTRY, type Pack, type PackRegistry, Palette, type SeedFields, type SeedOptions, TopperId, build, createAvatar, createRng, cyrb53, normalizeEmail, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, seedFromEmail, selectAvatar, sha256Hex };
package/dist/index.js CHANGED
@@ -1337,6 +1337,18 @@ function resolvePacks(ids) {
1337
1337
  }
1338
1338
 
1339
1339
  // src/select.ts
1340
+ var MOOD_EYES = {
1341
+ happy: "wide",
1342
+ serious: "squint",
1343
+ sleepy: "sleepy",
1344
+ wink: "wink"
1345
+ };
1346
+ var MOOD_MOUTH = {
1347
+ happy: "smile",
1348
+ serious: "flat",
1349
+ sleepy: "dot",
1350
+ wink: "smirk"
1351
+ };
1340
1352
  function applyStyleHint(pool, packs, hint, partKey) {
1341
1353
  for (const pack of packs) {
1342
1354
  const subset = pack.styleHints?.[hint]?.[partKey];
@@ -1398,10 +1410,13 @@ function selectAvatar(seed2, options = {}) {
1398
1410
  topperPool = applyStyleHint(topperPool, enabledPacks, styleHint, "topper");
1399
1411
  }
1400
1412
  const body = rng.pick(bodyPool);
1401
- const eyes = rng.pick(eyesPool);
1402
- const mouth = rng.pick(mouthPool);
1413
+ const eyesPicked = rng.pick(eyesPool);
1414
+ const mouthPicked = rng.pick(mouthPool);
1403
1415
  const antenna = rng.pick(antennaPool);
1404
1416
  const accessory = rng.pick(accessoryPool);
1417
+ const mood = options.mood;
1418
+ const eyes = mood && mood !== "neutral" ? MOOD_EYES[mood] : eyesPicked;
1419
+ const mouth = mood && mood !== "neutral" ? MOOD_MOUTH[mood] : mouthPicked;
1405
1420
  let background;
1406
1421
  if (typeof options.background === "string") {
1407
1422
  background = options.background;
@@ -1651,13 +1666,176 @@ function clamp(n, lo, hi) {
1651
1666
  return Math.max(lo, Math.min(hi, n));
1652
1667
  }
1653
1668
 
1669
+ // src/sha256.ts
1670
+ var K = new Uint32Array([
1671
+ 1116352408,
1672
+ 1899447441,
1673
+ 3049323471,
1674
+ 3921009573,
1675
+ 961987163,
1676
+ 1508970993,
1677
+ 2453635748,
1678
+ 2870763221,
1679
+ 3624381080,
1680
+ 310598401,
1681
+ 607225278,
1682
+ 1426881987,
1683
+ 1925078388,
1684
+ 2162078206,
1685
+ 2614888103,
1686
+ 3248222580,
1687
+ 3835390401,
1688
+ 4022224774,
1689
+ 264347078,
1690
+ 604807628,
1691
+ 770255983,
1692
+ 1249150122,
1693
+ 1555081692,
1694
+ 1996064986,
1695
+ 2554220882,
1696
+ 2821834349,
1697
+ 2952996808,
1698
+ 3210313671,
1699
+ 3336571891,
1700
+ 3584528711,
1701
+ 113926993,
1702
+ 338241895,
1703
+ 666307205,
1704
+ 773529912,
1705
+ 1294757372,
1706
+ 1396182291,
1707
+ 1695183700,
1708
+ 1986661051,
1709
+ 2177026350,
1710
+ 2456956037,
1711
+ 2730485921,
1712
+ 2820302411,
1713
+ 3259730800,
1714
+ 3345764771,
1715
+ 3516065817,
1716
+ 3600352804,
1717
+ 4094571909,
1718
+ 275423344,
1719
+ 430227734,
1720
+ 506948616,
1721
+ 659060556,
1722
+ 883997877,
1723
+ 958139571,
1724
+ 1322822218,
1725
+ 1537002063,
1726
+ 1747873779,
1727
+ 1955562222,
1728
+ 2024104815,
1729
+ 2227730452,
1730
+ 2361852424,
1731
+ 2428436474,
1732
+ 2756734187,
1733
+ 3204031479,
1734
+ 3329325298
1735
+ ]);
1736
+ function rotr(x, n) {
1737
+ return (x >>> n | x << 32 - n) >>> 0;
1738
+ }
1739
+ function utf8Encode(str) {
1740
+ if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(str);
1741
+ const out = [];
1742
+ for (let i = 0; i < str.length; i++) {
1743
+ let c = str.charCodeAt(i);
1744
+ if (c < 128) out.push(c);
1745
+ else if (c < 2048) {
1746
+ out.push(192 | c >> 6, 128 | c & 63);
1747
+ } else if (c < 55296 || c >= 57344) {
1748
+ out.push(224 | c >> 12, 128 | c >> 6 & 63, 128 | c & 63);
1749
+ } else {
1750
+ i++;
1751
+ c = 65536 + ((c & 1023) << 10 | str.charCodeAt(i) & 1023);
1752
+ out.push(
1753
+ 240 | c >> 18,
1754
+ 128 | c >> 12 & 63,
1755
+ 128 | c >> 6 & 63,
1756
+ 128 | c & 63
1757
+ );
1758
+ }
1759
+ }
1760
+ return new Uint8Array(out);
1761
+ }
1762
+ function sha256Hex(input) {
1763
+ const msg = utf8Encode(input);
1764
+ const bitLen = msg.length * 8;
1765
+ const padLen = (msg.length + 9 + 63 & -64) - msg.length;
1766
+ const buf = new Uint8Array(msg.length + padLen);
1767
+ buf.set(msg);
1768
+ buf[msg.length] = 128;
1769
+ const dv = new DataView(buf.buffer);
1770
+ dv.setUint32(buf.length - 4, bitLen >>> 0, false);
1771
+ dv.setUint32(buf.length - 8, Math.floor(bitLen / 4294967296), false);
1772
+ const H = new Uint32Array([
1773
+ 1779033703,
1774
+ 3144134277,
1775
+ 1013904242,
1776
+ 2773480762,
1777
+ 1359893119,
1778
+ 2600822924,
1779
+ 528734635,
1780
+ 1541459225
1781
+ ]);
1782
+ const W = new Uint32Array(64);
1783
+ for (let chunk = 0; chunk < buf.length; chunk += 64) {
1784
+ for (let i = 0; i < 16; i++) W[i] = dv.getUint32(chunk + i * 4, false);
1785
+ for (let i = 16; i < 64; i++) {
1786
+ const s0 = rotr(W[i - 15], 7) ^ rotr(W[i - 15], 18) ^ W[i - 15] >>> 3;
1787
+ const s1 = rotr(W[i - 2], 17) ^ rotr(W[i - 2], 19) ^ W[i - 2] >>> 10;
1788
+ W[i] = W[i - 16] + s0 + W[i - 7] + s1 >>> 0;
1789
+ }
1790
+ let a = H[0], b = H[1], c = H[2], d = H[3];
1791
+ let e = H[4], f = H[5], g = H[6], h = H[7];
1792
+ for (let i = 0; i < 64; i++) {
1793
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
1794
+ const ch = e & f ^ ~e & g;
1795
+ const t1 = h + S1 + ch + K[i] + W[i] >>> 0;
1796
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
1797
+ const mj = a & b ^ a & c ^ b & c;
1798
+ const t2 = S0 + mj >>> 0;
1799
+ h = g;
1800
+ g = f;
1801
+ f = e;
1802
+ e = d + t1 >>> 0;
1803
+ d = c;
1804
+ c = b;
1805
+ b = a;
1806
+ a = t1 + t2 >>> 0;
1807
+ }
1808
+ H[0] = H[0] + a >>> 0;
1809
+ H[1] = H[1] + b >>> 0;
1810
+ H[2] = H[2] + c >>> 0;
1811
+ H[3] = H[3] + d >>> 0;
1812
+ H[4] = H[4] + e >>> 0;
1813
+ H[5] = H[5] + f >>> 0;
1814
+ H[6] = H[6] + g >>> 0;
1815
+ H[7] = H[7] + h >>> 0;
1816
+ }
1817
+ let out = "";
1818
+ for (let i = 0; i < 8; i++) out += H[i].toString(16).padStart(8, "0");
1819
+ return out;
1820
+ }
1821
+
1654
1822
  // src/seed.ts
1655
- function seed(fields) {
1823
+ function normalizeEmail(email) {
1824
+ return email.trim().toLowerCase().normalize("NFC");
1825
+ }
1826
+ function seedFromEmail(email) {
1827
+ if (typeof email !== "string" || email.length === 0) {
1828
+ throw new Error("navii: seedFromEmail() requires a non-empty string");
1829
+ }
1830
+ return sha256Hex(normalizeEmail(email));
1831
+ }
1832
+ function seed(fields, options = {}) {
1833
+ const hashEmail = options.hashEmail ?? true;
1656
1834
  if (fields.id !== null && fields.id !== void 0 && String(fields.id).length > 0) {
1657
1835
  return String(fields.id);
1658
1836
  }
1659
1837
  if (fields.email && fields.email.length > 0) {
1660
- return fields.email;
1838
+ return hashEmail ? seedFromEmail(fields.email) : fields.email;
1661
1839
  }
1662
1840
  if (fields.name && fields.name.length > 0) {
1663
1841
  if (fields.createdAt !== null && fields.createdAt !== void 0) {
@@ -1672,7 +1850,7 @@ function seed(fields) {
1672
1850
 
1673
1851
  // src/build.ts
1674
1852
  function build(spec = {}, options = {}) {
1675
- const palette = spec.palette ? PALETTE_BY_ID[spec.palette] ?? PALETTES[0] : PALETTES[0];
1853
+ const palette = options.palette ?? (spec.palette ? PALETTE_BY_ID[spec.palette] ?? PALETTES[0] : PALETTES[0]);
1676
1854
  const resolved = {
1677
1855
  seed: "__build__",
1678
1856
  palette,
@@ -1716,9 +1894,10 @@ var Navii = {
1716
1894
  select: selectAvatar,
1717
1895
  group: renderGroup,
1718
1896
  seed,
1897
+ seedFromEmail,
1719
1898
  build
1720
1899
  };
1721
1900
 
1722
- export { BUILT_IN_PACKS, Navii, PACK_REGISTRY, build, createAvatar, createRng, cyrb53, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, selectAvatar };
1901
+ export { BUILT_IN_PACKS, Navii, PACK_REGISTRY, build, createAvatar, createRng, cyrb53, normalizeEmail, random, renderAvatar, renderAvatarInner, renderGroup, resolvePacks, seed, seedFromEmail, selectAvatar, sha256Hex };
1723
1902
  //# sourceMappingURL=index.js.map
1724
1903
  //# sourceMappingURL=index.js.map