@raster-app/sdk 0.1.0-canary-2e1834e

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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/__generated__/openapi.d.ts +1096 -0
  4. package/dist/__generated__/openapi.js +2 -0
  5. package/dist/__generated__/openapi.js.map +1 -0
  6. package/dist/adapters/index.d.ts +31 -0
  7. package/dist/adapters/index.js +2 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/local-storage.d.ts +3 -0
  10. package/dist/adapters/local-storage.js +49 -0
  11. package/dist/adapters/local-storage.js.map +1 -0
  12. package/dist/adapters/memory.d.ts +7 -0
  13. package/dist/adapters/memory.js +19 -0
  14. package/dist/adapters/memory.js.map +1 -0
  15. package/dist/api/assets.d.ts +41 -0
  16. package/dist/api/assets.js +70 -0
  17. package/dist/api/assets.js.map +1 -0
  18. package/dist/api/errors.d.ts +22 -0
  19. package/dist/api/errors.js +80 -0
  20. package/dist/api/errors.js.map +1 -0
  21. package/dist/api/libraries.d.ts +16 -0
  22. package/dist/api/libraries.js +22 -0
  23. package/dist/api/libraries.js.map +1 -0
  24. package/dist/api/me.d.ts +40 -0
  25. package/dist/api/me.js +39 -0
  26. package/dist/api/me.js.map +1 -0
  27. package/dist/api/promote.d.ts +36 -0
  28. package/dist/api/promote.js +29 -0
  29. package/dist/api/promote.js.map +1 -0
  30. package/dist/api/search.d.ts +25 -0
  31. package/dist/api/search.js +21 -0
  32. package/dist/api/search.js.map +1 -0
  33. package/dist/api/transport.d.ts +43 -0
  34. package/dist/api/transport.js +90 -0
  35. package/dist/api/transport.js.map +1 -0
  36. package/dist/api/upload.d.ts +40 -0
  37. package/dist/api/upload.js +60 -0
  38. package/dist/api/upload.js.map +1 -0
  39. package/dist/auth/device.d.ts +32 -0
  40. package/dist/auth/device.js +70 -0
  41. package/dist/auth/device.js.map +1 -0
  42. package/dist/auth/http.d.ts +25 -0
  43. package/dist/auth/http.js +70 -0
  44. package/dist/auth/http.js.map +1 -0
  45. package/dist/auth/register.d.ts +14 -0
  46. package/dist/auth/register.js +21 -0
  47. package/dist/auth/register.js.map +1 -0
  48. package/dist/auth/session.d.ts +30 -0
  49. package/dist/auth/session.js +231 -0
  50. package/dist/auth/session.js.map +1 -0
  51. package/dist/auth/tokens.d.ts +10 -0
  52. package/dist/auth/tokens.js +20 -0
  53. package/dist/auth/tokens.js.map +1 -0
  54. package/dist/client.d.ts +138 -0
  55. package/dist/client.js +122 -0
  56. package/dist/client.js.map +1 -0
  57. package/dist/constants.d.ts +18 -0
  58. package/dist/constants.js +24 -0
  59. package/dist/constants.js.map +1 -0
  60. package/dist/format.d.ts +13 -0
  61. package/dist/format.js +83 -0
  62. package/dist/format.js.map +1 -0
  63. package/dist/images/blurhash-placeholder.d.ts +5 -0
  64. package/dist/images/blurhash-placeholder.js +47 -0
  65. package/dist/images/blurhash-placeholder.js.map +1 -0
  66. package/dist/images/blurhash.d.ts +2 -0
  67. package/dist/images/blurhash.js +77 -0
  68. package/dist/images/blurhash.js.map +1 -0
  69. package/dist/images/image-src.d.ts +42 -0
  70. package/dist/images/image-src.js +83 -0
  71. package/dist/images/image-src.js.map +1 -0
  72. package/dist/images/sizes.d.ts +15 -0
  73. package/dist/images/sizes.js +41 -0
  74. package/dist/images/sizes.js.map +1 -0
  75. package/dist/index.d.ts +23 -0
  76. package/dist/index.js +17 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/types/asset.d.ts +50 -0
  79. package/dist/types/asset.js +23 -0
  80. package/dist/types/asset.js.map +1 -0
  81. package/dist/types/library.d.ts +21 -0
  82. package/dist/types/library.js +2 -0
  83. package/dist/types/library.js.map +1 -0
  84. package/dist/types/session.d.ts +59 -0
  85. package/dist/types/session.js +21 -0
  86. package/dist/types/session.js.map +1 -0
  87. package/package.json +42 -0
package/dist/client.js ADDED
@@ -0,0 +1,122 @@
1
+ import { getAsset, hasMorePages, listAssets } from './api/assets.js';
2
+ import { RasterApiError } from './api/errors.js';
3
+ import { listLibraries } from './api/libraries.js';
4
+ import { getMe, meCredentialOrganizations } from './api/me.js';
5
+ import { promoteVariant, waitUntilPromoted } from './api/promote.js';
6
+ import { searchAssets } from './api/search.js';
7
+ import { createTransport } from './api/transport.js';
8
+ import { uploadAsset, waitUntilProcessed } from './api/upload.js';
9
+ import { signInWithDevice } from './auth/device.js';
10
+ import { createSessionStore } from './auth/session.js';
11
+ import { revokeSession } from './auth/tokens.js';
12
+ import { API_ORIGIN, AUTH_ORIGIN } from './constants.js';
13
+ export function createRasterClient(config) {
14
+ const apiOrigin = config.apiOrigin ?? API_ORIGIN;
15
+ const authOrigin = config.authOrigin ?? AUTH_ORIGIN;
16
+ // Bound to globalThis: an unbound `fetch` throws `Illegal invocation` in a browser.
17
+ const fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
18
+ const auth = { authOrigin, fetch: fetchImpl };
19
+ const session = createSessionStore({ credentials: config.credentials, auth });
20
+ const transport = createTransport({ origin: apiOrigin, session, fetch: fetchImpl });
21
+ async function connect(credential, options = {}) {
22
+ const { organizationId, signal } = options;
23
+ if ('apiKey' in credential) {
24
+ // Copied keys often carry stray whitespace, which the API would refuse as another key.
25
+ credential = { apiKey: credential.apiKey.trim() };
26
+ if (credential.apiKey === '') {
27
+ throw new RasterApiError('Enter your API key to continue.', 'API_KEY_REQUIRED', 400);
28
+ }
29
+ }
30
+ const bearer = 'apiKey' in credential ? credential.apiKey : credential.oauth.accessToken;
31
+ const me = await getMe(transport, bearer, signal);
32
+ // Only an organization-bound grant names its organization at the top level. A grant of
33
+ // the whole account lists `organizations`, even when the account holds just that one.
34
+ if (organizationId !== undefined &&
35
+ ('organizations' in me || me.organizationId !== organizationId)) {
36
+ throw new RasterApiError('That sign-in doesn’t match the Raster organization this app uses. Sign in again and choose that organization.', 'ORGANIZATION_MISMATCH', 403);
37
+ }
38
+ const existing = session.current() ?? (await session.hydrate());
39
+ const next = {
40
+ // The registration outlives the session it was made for; see `StoredCredentials`.
41
+ clientId: 'oauth' in credential ? credential.oauth.clientId : (existing.clientId ?? null),
42
+ apiKey: 'apiKey' in credential ? credential.apiKey : null,
43
+ oauth: 'oauth' in credential ? { ...credential.oauth, signInId: crypto.randomUUID() } : null,
44
+ userName: me.user?.name ?? null,
45
+ organizations: meCredentialOrganizations(me),
46
+ };
47
+ // The last point a cancel can stop the sign-in, after `/me` and the hydrate awaits.
48
+ if (signal?.aborted)
49
+ throw new RasterApiError('Sign-in cancelled.', 'ABORTED', 0);
50
+ await session.save(next);
51
+ // Nobody holds the replaced session now. Left live, it would sit in Connected apps
52
+ // under this client's name beside the new one. Best-effort, like `signOut`.
53
+ const previous = existing.oauth;
54
+ if (previous !== null && previous.refreshToken !== next.oauth?.refreshToken) {
55
+ void revokeSession(auth, previous).catch(() => { });
56
+ }
57
+ return next;
58
+ }
59
+ return {
60
+ host: config.host,
61
+ session,
62
+ transport,
63
+ me: (bearer) => getMe(transport, bearer),
64
+ libraries: {
65
+ list: (options) => listLibraries(transport, options),
66
+ },
67
+ assets: {
68
+ list: (options) => listAssets(transport, options),
69
+ get: (options) => getAsset(transport, options),
70
+ upload: (options) => uploadAsset(transport, options),
71
+ waitUntilProcessed: (options) => waitUntilProcessed(transport, options),
72
+ promote: (options) => promoteVariant(transport, options),
73
+ waitUntilPromoted: (options) => waitUntilPromoted(transport, options),
74
+ hasMorePages,
75
+ },
76
+ search: (options) => searchAssets(transport, options),
77
+ auth: {
78
+ async signInWithDevice({ onCode, signal, openBrowser = true, scope = 'user', organizationId, }) {
79
+ const credentials = session.current() ?? (await session.hydrate());
80
+ const registration = { clientId: null };
81
+ let oauth = null;
82
+ try {
83
+ oauth = await signInWithDevice(auth, {
84
+ clientName: config.host.name,
85
+ // The token's audience: the real API even when `apiOrigin` is a proxy, since
86
+ // Raster refuses a resource it does not serve.
87
+ resource: API_ORIGIN,
88
+ clientId: credentials.clientId,
89
+ onRegister: (clientId) => {
90
+ registration.clientId = clientId;
91
+ },
92
+ scope,
93
+ onCode: (device) => {
94
+ onCode(device);
95
+ if (openBrowser)
96
+ void config.host.openExternal(device.verificationUrl);
97
+ },
98
+ signal,
99
+ });
100
+ return await connect({ oauth }, { organizationId, signal });
101
+ }
102
+ catch (caught) {
103
+ // Minted but never adopted, so nobody holds it: revoke rather than leave it live.
104
+ if (oauth !== null)
105
+ void revokeSession(auth, oauth).catch(() => { });
106
+ // Keep a client this attempt registered, so the next sign-in reuses it, and the saved
107
+ // credential as it was: an earlier session refreshes under its own client.
108
+ const kept = session.current();
109
+ const { clientId } = registration;
110
+ if (clientId !== null && kept !== undefined && kept.clientId !== clientId) {
111
+ await session.save({ ...kept, clientId }).catch(() => { });
112
+ }
113
+ throw caught;
114
+ }
115
+ },
116
+ // One argument only: a callback's extra arguments would reach the internal options.
117
+ connect: (credential) => connect(credential),
118
+ signOut: () => session.signOut(),
119
+ },
120
+ };
121
+ }
122
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CredentialStore, RasterHost } from './adapters/index.js'\nimport { getAsset, hasMorePages, listAssets } from './api/assets.js'\nimport { RasterApiError } from './api/errors.js'\nimport { listLibraries } from './api/libraries.js'\nimport { type Me, getMe, meCredentialOrganizations } from './api/me.js'\nimport { type PromoteResult, promoteVariant, waitUntilPromoted } from './api/promote.js'\nimport { type SearchResult, searchAssets } from './api/search.js'\nimport { type Transport, createTransport } from './api/transport.js'\nimport { type UploadSource, uploadAsset, waitUntilProcessed } from './api/upload.js'\nimport { type DeviceAuthorization, signInWithDevice } from './auth/device.js'\nimport type { AuthContext } from './auth/http.js'\nimport { type SessionStore, createSessionStore } from './auth/session.js'\nimport { revokeSession } from './auth/tokens.js'\nimport { API_ORIGIN, AUTH_ORIGIN } from './constants.js'\nimport type { Asset, UploadedAsset } from './types/asset.js'\nimport type { Library } from './types/library.js'\nimport type { OAuthSession, StoredCredentials } from './types/session.js'\n\nexport type RasterClientConfig = {\n\t/** Which application this is, and how it reaches the user's browser. */\n\thost: RasterHost\n\t/** Where the credential lives. See the security note on `CredentialStore`. */\n\tcredentials: CredentialStore\n\t/** Override for tests or a proxy. Defaults to `https://api.raster.app`. */\n\tapiOrigin?: string\n\t/** Override for tests. Defaults to `https://raster.app`. */\n\tauthOrigin?: string\n\t/** Override `fetch`. Defaults to the global one, bound to `globalThis`. */\n\tfetch?: typeof globalThis.fetch\n}\n\nexport interface RasterClient {\n\treadonly host: RasterHost\n\treadonly session: SessionStore\n\t/** Escape hatch: the raw authorized transport, for endpoints the SDK has not wrapped. */\n\treadonly transport: Transport\n\n\t/** Resolve what the current credential can reach. Doubles as credential validation. */\n\tme(bearer?: string): Promise<Me>\n\n\tlibraries: {\n\t\t/** Every library in the organization, however many pages that takes. */\n\t\tlist(options: {\n\t\t\torganizationId: string\n\t\t\torganizationName?: string | null\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<Array<Library>>\n\t}\n\n\tassets: {\n\t\tlist(options: {\n\t\t\torganizationId: string\n\t\t\tlibraryId: string\n\t\t\tpage?: number\n\t\t\tpageSize?: number\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<Array<Asset>>\n\t\tget(options: {\n\t\t\torganizationId: string\n\t\t\tlibraryId: string\n\t\t\tassetId: string\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<Asset>\n\t\tupload(options: {\n\t\t\torganizationId: string\n\t\t\tlibraryId: string\n\t\t\tparentId?: string | null\n\t\t\tsource: UploadSource\n\t\t\tfilename?: string\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<UploadedAsset>\n\t\t/**\n\t\t * Wait until Raster has processed an upload, which is after `upload` returns. Resolves\n\t\t * with the asset — for a variant, its parent, whose `variants` then carry it — or with\n\t\t * null if it was still processing when the wait gave up.\n\t\t */\n\t\twaitUntilProcessed(options: {\n\t\t\torganizationId: string\n\t\t\tlibraryId: string\n\t\t\tassetId: string\n\t\t\tparentId?: string | null\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<Asset | null>\n\t\tpromote(options: {\n\t\t\torganizationId: string\n\t\t\tlibraryId: string\n\t\t\tassetId: string\n\t\t\tvariantId: string\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<PromoteResult>\n\t\t/**\n\t\t * Wait until a promote has landed, which is after `promote` returns. Resolves with the\n\t\t * asset as it then reads, or with null if it had not landed when the wait gave up.\n\t\t */\n\t\twaitUntilPromoted(options: {\n\t\t\torganizationId: string\n\t\t\tlibraryId: string\n\t\t\tassetId: string\n\t\t\tvariantId: string\n\t\t\tsignal?: AbortSignal\n\t\t}): Promise<Asset | null>\n\t\thasMorePages: typeof hasMorePages\n\t}\n\n\tsearch(options: {\n\t\torganizationId: string\n\t\tquery: string\n\t\tlibraryIds?: Array<string>\n\t\tpage?: number\n\t\tpageSize?: number\n\t\tsignal?: AbortSignal\n\t}): Promise<SearchResult>\n\n\tauth: {\n\t\t/**\n\t\t * Sign in with the device grant (RFC 8628), which works in every host, a Figma plugin's\n\t\t * null-origin iframe included. Opens the consent page through `host.openExternal` as\n\t\t * soon as the code exists, unless `openBrowser: false` leaves that to the caller.\n\t\t */\n\t\tsignInWithDevice(options: {\n\t\t\tonCode: (device: DeviceAuthorization) => void\n\t\t\tsignal: AbortSignal\n\t\t\topenBrowser?: boolean\n\t\t\t/**\n\t\t\t * `user`, the default, lets the user grant every organization or narrow the grant to\n\t\t\t * one on the consent page; `org` has them pick exactly one. Access tokens last 60\n\t\t\t * minutes for one organization and 15 for every organization.\n\t\t\t */\n\t\t\tscope?: 'user' | 'org'\n\t\t\t/**\n\t\t\t * The organization the sign-in must be bound to. The consent page cannot preselect it,\n\t\t\t * so when the user grants another, or the whole account, the session is revoked\n\t\t\t * unsaved and the call fails with `ORGANIZATION_MISMATCH`.\n\t\t\t */\n\t\t\torganizationId?: string\n\t\t}): Promise<StoredCredentials>\n\n\t\t/**\n\t\t * Adopt a credential once `/me` accepts it, and persist what `/me` reports with it (the\n\t\t * account name, the organizations reached). The device sign-in ends here too. Verifying\n\t\t * first makes a bad key an error on the form rather than a broken session later.\n\t\t */\n\t\tconnect(credential: { apiKey: string } | { oauth: OAuthSession }): Promise<StoredCredentials>\n\n\t\tsignOut(): Promise<void>\n\t}\n}\n\nexport function createRasterClient(config: RasterClientConfig): RasterClient {\n\tconst apiOrigin = config.apiOrigin ?? API_ORIGIN\n\tconst authOrigin = config.authOrigin ?? AUTH_ORIGIN\n\t// Bound to globalThis: an unbound `fetch` throws `Illegal invocation` in a browser.\n\tconst fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis)\n\n\tconst auth: AuthContext = { authOrigin, fetch: fetchImpl }\n\tconst session = createSessionStore({ credentials: config.credentials, auth })\n\tconst transport = createTransport({ origin: apiOrigin, session, fetch: fetchImpl })\n\n\tasync function connect(\n\t\tcredential: { apiKey: string } | { oauth: OAuthSession },\n\t\toptions: { organizationId?: string; signal?: AbortSignal } = {}\n\t): Promise<StoredCredentials> {\n\t\tconst { organizationId, signal } = options\n\t\tif ('apiKey' in credential) {\n\t\t\t// Copied keys often carry stray whitespace, which the API would refuse as another key.\n\t\t\tcredential = { apiKey: credential.apiKey.trim() }\n\t\t\tif (credential.apiKey === '') {\n\t\t\t\tthrow new RasterApiError('Enter your API key to continue.', 'API_KEY_REQUIRED', 400)\n\t\t\t}\n\t\t}\n\t\tconst bearer = 'apiKey' in credential ? credential.apiKey : credential.oauth.accessToken\n\t\tconst me = await getMe(transport, bearer, signal)\n\t\t// Only an organization-bound grant names its organization at the top level. A grant of\n\t\t// the whole account lists `organizations`, even when the account holds just that one.\n\t\tif (\n\t\t\torganizationId !== undefined &&\n\t\t\t('organizations' in me || me.organizationId !== organizationId)\n\t\t) {\n\t\t\tthrow new RasterApiError(\n\t\t\t\t'That sign-in doesn’t match the Raster organization this app uses. Sign in again and choose that organization.',\n\t\t\t\t'ORGANIZATION_MISMATCH',\n\t\t\t\t403\n\t\t\t)\n\t\t}\n\n\t\tconst existing = session.current() ?? (await session.hydrate())\n\t\tconst next: StoredCredentials = {\n\t\t\t// The registration outlives the session it was made for; see `StoredCredentials`.\n\t\t\tclientId: 'oauth' in credential ? credential.oauth.clientId : (existing.clientId ?? null),\n\t\t\tapiKey: 'apiKey' in credential ? credential.apiKey : null,\n\t\t\toauth: 'oauth' in credential ? { ...credential.oauth, signInId: crypto.randomUUID() } : null,\n\t\t\tuserName: me.user?.name ?? null,\n\t\t\torganizations: meCredentialOrganizations(me),\n\t\t}\n\t\t// The last point a cancel can stop the sign-in, after `/me` and the hydrate awaits.\n\t\tif (signal?.aborted) throw new RasterApiError('Sign-in cancelled.', 'ABORTED', 0)\n\t\tawait session.save(next)\n\n\t\t// Nobody holds the replaced session now. Left live, it would sit in Connected apps\n\t\t// under this client's name beside the new one. Best-effort, like `signOut`.\n\t\tconst previous = existing.oauth\n\t\tif (previous !== null && previous.refreshToken !== next.oauth?.refreshToken) {\n\t\t\tvoid revokeSession(auth, previous).catch(() => {})\n\t\t}\n\t\treturn next\n\t}\n\n\treturn {\n\t\thost: config.host,\n\t\tsession,\n\t\ttransport,\n\n\t\tme: (bearer) => getMe(transport, bearer),\n\n\t\tlibraries: {\n\t\t\tlist: (options) => listLibraries(transport, options),\n\t\t},\n\n\t\tassets: {\n\t\t\tlist: (options) => listAssets(transport, options),\n\t\t\tget: (options) => getAsset(transport, options),\n\t\t\tupload: (options) => uploadAsset(transport, options),\n\t\t\twaitUntilProcessed: (options) => waitUntilProcessed(transport, options),\n\t\t\tpromote: (options) => promoteVariant(transport, options),\n\t\t\twaitUntilPromoted: (options) => waitUntilPromoted(transport, options),\n\t\t\thasMorePages,\n\t\t},\n\n\t\tsearch: (options) => searchAssets(transport, options),\n\n\t\tauth: {\n\t\t\tasync signInWithDevice({\n\t\t\t\tonCode,\n\t\t\t\tsignal,\n\t\t\t\topenBrowser = true,\n\t\t\t\tscope = 'user',\n\t\t\t\torganizationId,\n\t\t\t}) {\n\t\t\t\tconst credentials = session.current() ?? (await session.hydrate())\n\t\t\t\tconst registration: { clientId: string | null } = { clientId: null }\n\t\t\t\tlet oauth: OAuthSession | null = null\n\t\t\t\ttry {\n\t\t\t\t\toauth = await signInWithDevice(auth, {\n\t\t\t\t\t\tclientName: config.host.name,\n\t\t\t\t\t\t// The token's audience: the real API even when `apiOrigin` is a proxy, since\n\t\t\t\t\t\t// Raster refuses a resource it does not serve.\n\t\t\t\t\t\tresource: API_ORIGIN,\n\t\t\t\t\t\tclientId: credentials.clientId,\n\t\t\t\t\t\tonRegister: (clientId) => {\n\t\t\t\t\t\t\tregistration.clientId = clientId\n\t\t\t\t\t\t},\n\t\t\t\t\t\tscope,\n\t\t\t\t\t\tonCode: (device) => {\n\t\t\t\t\t\t\tonCode(device)\n\t\t\t\t\t\t\tif (openBrowser) void config.host.openExternal(device.verificationUrl)\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t})\n\t\t\t\t\treturn await connect({ oauth }, { organizationId, signal })\n\t\t\t\t} catch (caught) {\n\t\t\t\t\t// Minted but never adopted, so nobody holds it: revoke rather than leave it live.\n\t\t\t\t\tif (oauth !== null) void revokeSession(auth, oauth).catch(() => {})\n\t\t\t\t\t// Keep a client this attempt registered, so the next sign-in reuses it, and the saved\n\t\t\t\t\t// credential as it was: an earlier session refreshes under its own client.\n\t\t\t\t\tconst kept = session.current()\n\t\t\t\t\tconst { clientId } = registration\n\t\t\t\t\tif (clientId !== null && kept !== undefined && kept.clientId !== clientId) {\n\t\t\t\t\t\tawait session.save({ ...kept, clientId }).catch(() => {})\n\t\t\t\t\t}\n\t\t\t\t\tthrow caught\n\t\t\t\t}\n\t\t\t},\n\n\t\t\t// One argument only: a callback's extra arguments would reach the internal options.\n\t\t\tconnect: (credential) => connect(credential),\n\n\t\t\tsignOut: () => session.signOut(),\n\t\t},\n\t}\n}\n"]}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The Raster REST API. Also the OAuth resource every token is minted for (RFC 8707), so a
3
+ * token meant for another Raster host is refused here and vice versa.
4
+ */
5
+ export declare const API_ORIGIN = "https://api.raster.app";
6
+ /** The Raster authorization server, described at `/.well-known/oauth-authorization-server`. */
7
+ export declare const AUTH_ORIGIN = "https://raster.app";
8
+ /**
9
+ * The API version the SDK is written against, sent on every request. Payload shapes vary by
10
+ * version (`Vary: Api-Version`), so bumping it is a minor release with a changelog entry.
11
+ */
12
+ export declare const API_VERSION = "2026-07-08";
13
+ /**
14
+ * Every Raster host the SDK reaches: the API, the auth server and the two CDNs. A Figma
15
+ * plugin must list each in its manifest's `networkAccess.allowedDomains`, or requests to it
16
+ * fail as `NETWORK_ERROR`; checking the manifest against this list keeps the two in step.
17
+ */
18
+ export declare const RASTER_HOSTS: readonly ["https://raster.app", "https://api.raster.app", "https://cdn.raster.app", "https://rstr.in"];
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The Raster REST API. Also the OAuth resource every token is minted for (RFC 8707), so a
3
+ * token meant for another Raster host is refused here and vice versa.
4
+ */
5
+ export const API_ORIGIN = 'https://api.raster.app';
6
+ /** The Raster authorization server, described at `/.well-known/oauth-authorization-server`. */
7
+ export const AUTH_ORIGIN = 'https://raster.app';
8
+ /**
9
+ * The API version the SDK is written against, sent on every request. Payload shapes vary by
10
+ * version (`Vary: Api-Version`), so bumping it is a minor release with a changelog entry.
11
+ */
12
+ export const API_VERSION = '2026-07-08';
13
+ /**
14
+ * Every Raster host the SDK reaches: the API, the auth server and the two CDNs. A Figma
15
+ * plugin must list each in its manifest's `networkAccess.allowedDomains`, or requests to it
16
+ * fail as `NETWORK_ERROR`; checking the manifest against this list keeps the two in step.
17
+ */
18
+ export const RASTER_HOSTS = [
19
+ 'https://raster.app',
20
+ 'https://api.raster.app',
21
+ 'https://cdn.raster.app',
22
+ 'https://rstr.in',
23
+ ];
24
+ //# sourceMappingURL=constants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,wBAAwB,CAAA;AAElD,+FAA+F;AAC/F,MAAM,CAAC,MAAM,WAAW,GAAG,oBAAoB,CAAA;AAE/C;;;GAGG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,YAAY,CAAA;AAEvC;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,oBAAoB;IACpB,wBAAwB;IACxB,wBAAwB;IACxB,iBAAiB;CACR,CAAA","sourcesContent":["/**\n * The Raster REST API. Also the OAuth resource every token is minted for (RFC 8707), so a\n * token meant for another Raster host is refused here and vice versa.\n */\nexport const API_ORIGIN = 'https://api.raster.app'\n\n/** The Raster authorization server, described at `/.well-known/oauth-authorization-server`. */\nexport const AUTH_ORIGIN = 'https://raster.app'\n\n/**\n * The API version the SDK is written against, sent on every request. Payload shapes vary by\n * version (`Vary: Api-Version`), so bumping it is a minor release with a changelog entry.\n */\nexport const API_VERSION = '2026-07-08'\n\n/**\n * Every Raster host the SDK reaches: the API, the auth server and the two CDNs. A Figma\n * plugin must list each in its manifest's `networkAccess.allowedDomains`, or requests to it\n * fail as `NETWORK_ERROR`; checking the manifest against this list keeps the two in step.\n */\nexport const RASTER_HOSTS = [\n\t'https://raster.app',\n\t'https://api.raster.app',\n\t'https://cdn.raster.app',\n\t'https://rstr.in',\n] as const\n"]}
@@ -0,0 +1,13 @@
1
+ /** Decimal (SI) byte units, as the Raster app shows sizes and storage quotas. */
2
+ export declare function formatBytes(bytes: number): string;
3
+ /** An asset's `size`, or empty when it has none. */
4
+ export declare function formatFileSize(bytes: number | null | undefined): string;
5
+ /** `Jan 15, 2024`, in `en-US` as the Raster app shows dates. Empty for null. */
6
+ export declare function formatDate(date: Date | number | null): string;
7
+ /** The date with its weekday and time, in `en-US`. Empty for null. */
8
+ export declare function formatDateLong(date: Date | number | null): string;
9
+ /**
10
+ * A short relative label ("just now", "5m ago", "3h ago", "2d ago"), or `formatDate` once
11
+ * the date is 7 days old. Empty for null. `now` is injectable for deterministic tests.
12
+ */
13
+ export declare function formatRelativeTime(date: Date | number | null, now?: Date): string;
package/dist/format.js ADDED
@@ -0,0 +1,83 @@
1
+ const BYTES_PER_KB = 1000;
2
+ const BYTES_PER_MB = 1000 * BYTES_PER_KB;
3
+ const BYTES_PER_GB = 1000 * BYTES_PER_MB;
4
+ const BYTES_PER_TB = 1000 * BYTES_PER_GB;
5
+ /** Decimal (SI) byte units, as the Raster app shows sizes and storage quotas. */
6
+ export function formatBytes(bytes) {
7
+ if (bytes >= BYTES_PER_TB)
8
+ return `${(bytes / BYTES_PER_TB).toFixed(2)} TB`;
9
+ if (bytes >= BYTES_PER_GB)
10
+ return `${(bytes / BYTES_PER_GB).toFixed(2)} GB`;
11
+ if (bytes >= BYTES_PER_MB)
12
+ return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`;
13
+ if (bytes >= BYTES_PER_KB)
14
+ return `${(bytes / BYTES_PER_KB).toFixed(0)} KB`;
15
+ return `${bytes} B`;
16
+ }
17
+ /** An asset's `size`, or empty when it has none. */
18
+ export function formatFileSize(bytes) {
19
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0)
20
+ return '';
21
+ return formatBytes(bytes);
22
+ }
23
+ /** A `Date`, or the Unix milliseconds `Asset.created` and `updated` hold. */
24
+ function toDate(date) {
25
+ return typeof date === 'number' ? new Date(date) : date;
26
+ }
27
+ /** `Jan 15, 2024`, in `en-US` as the Raster app shows dates. Empty for null. */
28
+ export function formatDate(date) {
29
+ const value = toDate(date);
30
+ if (!value)
31
+ return '';
32
+ return value.toLocaleDateString('en-US', {
33
+ month: 'short',
34
+ day: 'numeric',
35
+ year: 'numeric',
36
+ });
37
+ }
38
+ /** The date with its weekday and time, in `en-US`. Empty for null. */
39
+ export function formatDateLong(date) {
40
+ const value = toDate(date);
41
+ if (!value)
42
+ return '';
43
+ return value.toLocaleDateString('en-US', {
44
+ weekday: 'long',
45
+ month: 'long',
46
+ day: 'numeric',
47
+ year: 'numeric',
48
+ hour: 'numeric',
49
+ minute: '2-digit',
50
+ });
51
+ }
52
+ const SECOND_MS = 1000;
53
+ const MINUTE_MS = 60 * SECOND_MS;
54
+ const HOUR_MS = 60 * MINUTE_MS;
55
+ const DAY_MS = 24 * HOUR_MS;
56
+ /** Beyond this age, a relative label is less useful than an absolute date. */
57
+ const RELATIVE_TIME_FALLBACK_MS = 7 * DAY_MS;
58
+ /**
59
+ * A short relative label ("just now", "5m ago", "3h ago", "2d ago"), or `formatDate` once
60
+ * the date is 7 days old. Empty for null. `now` is injectable for deterministic tests.
61
+ */
62
+ export function formatRelativeTime(date, now = new Date()) {
63
+ const value = toDate(date);
64
+ if (!value)
65
+ return '';
66
+ const diffMs = now.getTime() - value.getTime();
67
+ if (diffMs >= RELATIVE_TIME_FALLBACK_MS)
68
+ return formatDate(value);
69
+ if (diffMs < MINUTE_MS)
70
+ return 'just now';
71
+ const relative = new Intl.RelativeTimeFormat('en-US', {
72
+ numeric: 'always',
73
+ style: 'narrow',
74
+ });
75
+ if (diffMs < HOUR_MS) {
76
+ return relative.format(-Math.floor(diffMs / MINUTE_MS), 'minute');
77
+ }
78
+ if (diffMs < DAY_MS) {
79
+ return relative.format(-Math.floor(diffMs / HOUR_MS), 'hour');
80
+ }
81
+ return relative.format(-Math.floor(diffMs / DAY_MS), 'day');
82
+ }
83
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"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","sourcesContent":["const BYTES_PER_KB = 1000\nconst BYTES_PER_MB = 1000 * BYTES_PER_KB\nconst BYTES_PER_GB = 1000 * BYTES_PER_MB\nconst BYTES_PER_TB = 1000 * BYTES_PER_GB\n\n/** Decimal (SI) byte units, as the Raster app shows sizes and storage quotas. */\nexport function formatBytes(bytes: number): string {\n\tif (bytes >= BYTES_PER_TB) return `${(bytes / BYTES_PER_TB).toFixed(2)} TB`\n\tif (bytes >= BYTES_PER_GB) return `${(bytes / BYTES_PER_GB).toFixed(2)} GB`\n\tif (bytes >= BYTES_PER_MB) return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`\n\tif (bytes >= BYTES_PER_KB) return `${(bytes / BYTES_PER_KB).toFixed(0)} KB`\n\treturn `${bytes} B`\n}\n\n/** An asset's `size`, or empty when it has none. */\nexport function formatFileSize(bytes: number | null | undefined): string {\n\tif (bytes == null || !Number.isFinite(bytes) || bytes < 0) return ''\n\treturn formatBytes(bytes)\n}\n\n/** A `Date`, or the Unix milliseconds `Asset.created` and `updated` hold. */\nfunction toDate(date: Date | number | null): Date | null {\n\treturn typeof date === 'number' ? new Date(date) : date\n}\n\n/** `Jan 15, 2024`, in `en-US` as the Raster app shows dates. Empty for null. */\nexport function formatDate(date: Date | number | null): string {\n\tconst value = toDate(date)\n\tif (!value) return ''\n\treturn value.toLocaleDateString('en-US', {\n\t\tmonth: 'short',\n\t\tday: 'numeric',\n\t\tyear: 'numeric',\n\t})\n}\n\n/** The date with its weekday and time, in `en-US`. Empty for null. */\nexport function formatDateLong(date: Date | number | null): string {\n\tconst value = toDate(date)\n\tif (!value) return ''\n\treturn value.toLocaleDateString('en-US', {\n\t\tweekday: 'long',\n\t\tmonth: 'long',\n\t\tday: 'numeric',\n\t\tyear: 'numeric',\n\t\thour: 'numeric',\n\t\tminute: '2-digit',\n\t})\n}\n\nconst SECOND_MS = 1_000\nconst MINUTE_MS = 60 * SECOND_MS\nconst HOUR_MS = 60 * MINUTE_MS\nconst DAY_MS = 24 * HOUR_MS\n/** Beyond this age, a relative label is less useful than an absolute date. */\nconst RELATIVE_TIME_FALLBACK_MS = 7 * DAY_MS\n\n/**\n * A short relative label (\"just now\", \"5m ago\", \"3h ago\", \"2d ago\"), or `formatDate` once\n * the date is 7 days old. Empty for null. `now` is injectable for deterministic tests.\n */\nexport function formatRelativeTime(date: Date | number | null, now: Date = new Date()): string {\n\tconst value = toDate(date)\n\tif (!value) return ''\n\n\tconst diffMs = now.getTime() - value.getTime()\n\tif (diffMs >= RELATIVE_TIME_FALLBACK_MS) return formatDate(value)\n\tif (diffMs < MINUTE_MS) return 'just now'\n\n\tconst relative = new Intl.RelativeTimeFormat('en-US', {\n\t\tnumeric: 'always',\n\t\tstyle: 'narrow',\n\t})\n\n\tif (diffMs < HOUR_MS) {\n\t\treturn relative.format(-Math.floor(diffMs / MINUTE_MS), 'minute')\n\t}\n\tif (diffMs < DAY_MS) {\n\t\treturn relative.format(-Math.floor(diffMs / HOUR_MS), 'hour')\n\t}\n\treturn relative.format(-Math.floor(diffMs / DAY_MS), 'day')\n}\n"]}
@@ -0,0 +1,5 @@
1
+ /**
2
+ * A BlurHash as a tiny PNG `data:` URL, to paint behind a thumbnail while it loads. Null for a
3
+ * missing or malformed hash, or without a `document` to draw it with.
4
+ */
5
+ export declare function blurhashToDataUrl(hash: string | null | undefined): string | null;
@@ -0,0 +1,47 @@
1
+ import { decodeBlurhash } from './blurhash.js';
2
+ /** The browser upscales a smooth gradient with no visible blocks, so 32×32 is plenty. */
3
+ const PLACEHOLDER_EDGE = 32;
4
+ /** Grid cells re-render on scroll, so decodes are cached, up to this many hashes. */
5
+ const CACHE_LIMIT = 400;
6
+ /** A hash that fails to decode is cached as null, so it is not decoded again every frame. */
7
+ const cache = new Map();
8
+ /**
9
+ * A BlurHash as a tiny PNG `data:` URL, to paint behind a thumbnail while it loads. Null for a
10
+ * missing or malformed hash, or without a `document` to draw it with.
11
+ */
12
+ export function blurhashToDataUrl(hash) {
13
+ if (!hash)
14
+ return null;
15
+ if (cache.has(hash))
16
+ return cache.get(hash) ?? null;
17
+ // Not cached: once a server-rendered page hydrates, the first client render retries.
18
+ if (typeof document === 'undefined')
19
+ return null;
20
+ let dataUrl = null;
21
+ try {
22
+ const pixels = decodeBlurhash(hash, PLACEHOLDER_EDGE, PLACEHOLDER_EDGE);
23
+ const canvas = document.createElement('canvas');
24
+ canvas.width = PLACEHOLDER_EDGE;
25
+ canvas.height = PLACEHOLDER_EDGE;
26
+ const context = canvas.getContext('2d');
27
+ if (context) {
28
+ const imageData = context.createImageData(PLACEHOLDER_EDGE, PLACEHOLDER_EDGE);
29
+ imageData.data.set(pixels);
30
+ context.putImageData(imageData, 0, 0);
31
+ dataUrl = canvas.toDataURL();
32
+ }
33
+ }
34
+ catch {
35
+ // A render path must not throw.
36
+ dataUrl = null;
37
+ }
38
+ // FIFO-evict the oldest entry when full (Map preserves insertion order).
39
+ if (cache.size >= CACHE_LIMIT) {
40
+ const oldest = cache.keys().next().value;
41
+ if (oldest !== undefined)
42
+ cache.delete(oldest);
43
+ }
44
+ cache.set(hash, dataUrl);
45
+ return dataUrl;
46
+ }
47
+ //# sourceMappingURL=blurhash-placeholder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blurhash-placeholder.js","sourceRoot":"","sources":["../../src/images/blurhash-placeholder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAE9C,yFAAyF;AACzF,MAAM,gBAAgB,GAAG,EAAE,CAAA;AAC3B,qFAAqF;AACrF,MAAM,WAAW,GAAG,GAAG,CAAA;AACvB,6FAA6F;AAC7F,MAAM,KAAK,GAAG,IAAI,GAAG,EAAyB,CAAA;AAE9C;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAA+B;IAChE,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IAEtB,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAA;IAEnD,qFAAqF;IACrF,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IAEhD,IAAI,OAAO,GAAkB,IAAI,CAAA;IACjC,IAAI,CAAC;QACJ,MAAM,MAAM,GAAG,cAAc,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,CAAA;QACvE,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;QAC/C,MAAM,CAAC,KAAK,GAAG,gBAAgB,CAAA;QAC/B,MAAM,CAAC,MAAM,GAAG,gBAAgB,CAAA;QAChC,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAA;QACvC,IAAI,OAAO,EAAE,CAAC;YACb,MAAM,SAAS,GAAG,OAAO,CAAC,eAAe,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,CAAA;YAC7E,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;YAC1B,OAAO,CAAC,YAAY,CAAC,SAAS,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;YACrC,OAAO,GAAG,MAAM,CAAC,SAAS,EAAE,CAAA;QAC7B,CAAC;IACF,CAAC;IAAC,MAAM,CAAC;QACR,gCAAgC;QAChC,OAAO,GAAG,IAAI,CAAA;IACf,CAAC;IAED,yEAAyE;IACzE,IAAI,KAAK,CAAC,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,CAAA;QACxC,IAAI,MAAM,KAAK,SAAS;YAAE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IAC/C,CAAC;IACD,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;IACxB,OAAO,OAAO,CAAA;AACf,CAAC","sourcesContent":["import { decodeBlurhash } from './blurhash.js'\n\n/** The browser upscales a smooth gradient with no visible blocks, so 32×32 is plenty. */\nconst PLACEHOLDER_EDGE = 32\n/** Grid cells re-render on scroll, so decodes are cached, up to this many hashes. */\nconst CACHE_LIMIT = 400\n/** A hash that fails to decode is cached as null, so it is not decoded again every frame. */\nconst cache = new Map<string, string | null>()\n\n/**\n * A BlurHash as a tiny PNG `data:` URL, to paint behind a thumbnail while it loads. Null for a\n * missing or malformed hash, or without a `document` to draw it with.\n */\nexport function blurhashToDataUrl(hash: string | null | undefined): string | null {\n\tif (!hash) return null\n\n\tif (cache.has(hash)) return cache.get(hash) ?? null\n\n\t// Not cached: once a server-rendered page hydrates, the first client render retries.\n\tif (typeof document === 'undefined') return null\n\n\tlet dataUrl: string | null = null\n\ttry {\n\t\tconst pixels = decodeBlurhash(hash, PLACEHOLDER_EDGE, PLACEHOLDER_EDGE)\n\t\tconst canvas = document.createElement('canvas')\n\t\tcanvas.width = PLACEHOLDER_EDGE\n\t\tcanvas.height = PLACEHOLDER_EDGE\n\t\tconst context = canvas.getContext('2d')\n\t\tif (context) {\n\t\t\tconst imageData = context.createImageData(PLACEHOLDER_EDGE, PLACEHOLDER_EDGE)\n\t\t\timageData.data.set(pixels)\n\t\t\tcontext.putImageData(imageData, 0, 0)\n\t\t\tdataUrl = canvas.toDataURL()\n\t\t}\n\t} catch {\n\t\t// A render path must not throw.\n\t\tdataUrl = null\n\t}\n\n\t// FIFO-evict the oldest entry when full (Map preserves insertion order).\n\tif (cache.size >= CACHE_LIMIT) {\n\t\tconst oldest = cache.keys().next().value\n\t\tif (oldest !== undefined) cache.delete(oldest)\n\t}\n\tcache.set(hash, dataUrl)\n\treturn dataUrl\n}\n"]}
@@ -0,0 +1,2 @@
1
+ /** Decode a BlurHash into RGBA pixel bytes of the requested size. `punch` boosts contrast. */
2
+ export declare function decodeBlurhash(hash: string, width: number, height: number, punch?: number): Uint8ClampedArray;
@@ -0,0 +1,77 @@
1
+ // The 83-character alphabet the BlurHash wire format packs integers into.
2
+ const BASE83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~';
3
+ function base83Decode(text) {
4
+ let value = 0;
5
+ for (const character of text) {
6
+ const digit = BASE83.indexOf(character);
7
+ if (digit === -1)
8
+ throw new Error(`Invalid base83 character: ${character}`);
9
+ value = value * 83 + digit;
10
+ }
11
+ return value;
12
+ }
13
+ // sRGB gamma -> linear light (standard IEC 61966-2-1 transfer function).
14
+ function toLinear(srgb) {
15
+ const channel = srgb / 255;
16
+ return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
17
+ }
18
+ function toSrgb(linear) {
19
+ const clamped = Math.max(0, Math.min(1, linear));
20
+ const channel = clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055;
21
+ return Math.trunc(channel * 255 + 0.5);
22
+ }
23
+ function signedPow(value, exponent) {
24
+ return (value < 0 ? -1 : 1) * Math.abs(value) ** exponent;
25
+ }
26
+ /** Decode a BlurHash into RGBA pixel bytes of the requested size. `punch` boosts contrast. */
27
+ export function decodeBlurhash(hash, width, height, punch = 1) {
28
+ const sizeFlag = base83Decode(hash[0] ?? '');
29
+ const componentsX = (sizeFlag % 9) + 1;
30
+ const componentsY = Math.floor(sizeFlag / 9) + 1;
31
+ const maximumValue = (base83Decode(hash[1] ?? '') + 1) / 166;
32
+ const colors = [];
33
+ const componentCount = componentsX * componentsY;
34
+ for (let index = 0; index < componentCount; index++) {
35
+ if (index === 0) {
36
+ const value = base83Decode(hash.substring(2, 6));
37
+ colors.push([toLinear(value >> 16), toLinear((value >> 8) & 255), toLinear(value & 255)]);
38
+ }
39
+ else {
40
+ const value = base83Decode(hash.substring(4 + index * 2, 6 + index * 2));
41
+ const scale = maximumValue * punch;
42
+ const quantR = Math.floor(value / (19 * 19));
43
+ const quantG = Math.floor(value / 19) % 19;
44
+ const quantB = value % 19;
45
+ colors.push([
46
+ signedPow((quantR - 9) / 9, 2) * scale,
47
+ signedPow((quantG - 9) / 9, 2) * scale,
48
+ signedPow((quantB - 9) / 9, 2) * scale,
49
+ ]);
50
+ }
51
+ }
52
+ const bytesPerRow = width * 4;
53
+ const pixels = new Uint8ClampedArray(bytesPerRow * height);
54
+ for (let y = 0; y < height; y++) {
55
+ for (let x = 0; x < width; x++) {
56
+ let red = 0;
57
+ let green = 0;
58
+ let blue = 0;
59
+ for (let cy = 0; cy < componentsY; cy++) {
60
+ for (let cx = 0; cx < componentsX; cx++) {
61
+ const basis = Math.cos((Math.PI * x * cx) / width) * Math.cos((Math.PI * y * cy) / height);
62
+ const color = colors[cx + cy * componentsX] ?? [0, 0, 0];
63
+ red += color[0] * basis;
64
+ green += color[1] * basis;
65
+ blue += color[2] * basis;
66
+ }
67
+ }
68
+ const index = 4 * x + y * bytesPerRow;
69
+ pixels[index] = toSrgb(red);
70
+ pixels[index + 1] = toSrgb(green);
71
+ pixels[index + 2] = toSrgb(blue);
72
+ pixels[index + 3] = 255;
73
+ }
74
+ }
75
+ return pixels;
76
+ }
77
+ //# sourceMappingURL=blurhash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blurhash.js","sourceRoot":"","sources":["../../src/images/blurhash.ts"],"names":[],"mappings":"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","sourcesContent":["// The 83-character alphabet the BlurHash wire format packs integers into.\nconst BASE83 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'\n\nfunction base83Decode(text: string): number {\n\tlet value = 0\n\tfor (const character of text) {\n\t\tconst digit = BASE83.indexOf(character)\n\t\tif (digit === -1) throw new Error(`Invalid base83 character: ${character}`)\n\t\tvalue = value * 83 + digit\n\t}\n\treturn value\n}\n\n// sRGB gamma -> linear light (standard IEC 61966-2-1 transfer function).\nfunction toLinear(srgb: number): number {\n\tconst channel = srgb / 255\n\treturn channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4\n}\n\nfunction toSrgb(linear: number): number {\n\tconst clamped = Math.max(0, Math.min(1, linear))\n\tconst channel = clamped <= 0.0031308 ? clamped * 12.92 : 1.055 * clamped ** (1 / 2.4) - 0.055\n\treturn Math.trunc(channel * 255 + 0.5)\n}\n\nfunction signedPow(value: number, exponent: number): number {\n\treturn (value < 0 ? -1 : 1) * Math.abs(value) ** exponent\n}\n\ntype Rgb = [number, number, number]\n\n/** Decode a BlurHash into RGBA pixel bytes of the requested size. `punch` boosts contrast. */\nexport function decodeBlurhash(\n\thash: string,\n\twidth: number,\n\theight: number,\n\tpunch = 1\n): Uint8ClampedArray {\n\tconst sizeFlag = base83Decode(hash[0] ?? '')\n\tconst componentsX = (sizeFlag % 9) + 1\n\tconst componentsY = Math.floor(sizeFlag / 9) + 1\n\tconst maximumValue = (base83Decode(hash[1] ?? '') + 1) / 166\n\n\tconst colors: Rgb[] = []\n\tconst componentCount = componentsX * componentsY\n\tfor (let index = 0; index < componentCount; index++) {\n\t\tif (index === 0) {\n\t\t\tconst value = base83Decode(hash.substring(2, 6))\n\t\t\tcolors.push([toLinear(value >> 16), toLinear((value >> 8) & 255), toLinear(value & 255)])\n\t\t} else {\n\t\t\tconst value = base83Decode(hash.substring(4 + index * 2, 6 + index * 2))\n\t\t\tconst scale = maximumValue * punch\n\t\t\tconst quantR = Math.floor(value / (19 * 19))\n\t\t\tconst quantG = Math.floor(value / 19) % 19\n\t\t\tconst quantB = value % 19\n\t\t\tcolors.push([\n\t\t\t\tsignedPow((quantR - 9) / 9, 2) * scale,\n\t\t\t\tsignedPow((quantG - 9) / 9, 2) * scale,\n\t\t\t\tsignedPow((quantB - 9) / 9, 2) * scale,\n\t\t\t])\n\t\t}\n\t}\n\n\tconst bytesPerRow = width * 4\n\tconst pixels = new Uint8ClampedArray(bytesPerRow * height)\n\tfor (let y = 0; y < height; y++) {\n\t\tfor (let x = 0; x < width; x++) {\n\t\t\tlet red = 0\n\t\t\tlet green = 0\n\t\t\tlet blue = 0\n\t\t\tfor (let cy = 0; cy < componentsY; cy++) {\n\t\t\t\tfor (let cx = 0; cx < componentsX; cx++) {\n\t\t\t\t\tconst basis = Math.cos((Math.PI * x * cx) / width) * Math.cos((Math.PI * y * cy) / height)\n\t\t\t\t\tconst color = colors[cx + cy * componentsX] ?? [0, 0, 0]\n\t\t\t\t\tred += color[0] * basis\n\t\t\t\t\tgreen += color[1] * basis\n\t\t\t\t\tblue += color[2] * basis\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst index = 4 * x + y * bytesPerRow\n\t\t\tpixels[index] = toSrgb(red)\n\t\t\tpixels[index + 1] = toSrgb(green)\n\t\t\tpixels[index + 2] = toSrgb(blue)\n\t\t\tpixels[index + 3] = 255\n\t\t}\n\t}\n\treturn pixels\n}\n"]}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Append `?v=` so a cache holding this URL serves the current pixels. A promote replaces the
3
+ * file behind a stable URL, and `contentVersion` changes exactly when the bytes do. The CDN
4
+ * caches each `v` apart, so a value that changes more often, such as `updated`, would mint a
5
+ * cache entry per rename. An asset with no version keeps the bare URL others share.
6
+ */
7
+ export declare function versionedUrl(url: string, contentVersion: number | null | undefined): string;
8
+ /**
9
+ * The thumbnail `src` for an asset: a smaller size from `sizes`, or the full-size `url` until
10
+ * the sizes exist or when the source is smaller than that size, carrying the content version
11
+ * when the asset has one.
12
+ */
13
+ export declare function getThumbUrl(asset: {
14
+ sizes?: string | null;
15
+ url?: string | null;
16
+ contentVersion?: number | null;
17
+ displaySizes?: string | null;
18
+ displayUrl?: string | null;
19
+ }): string | undefined;
20
+ /**
21
+ * The `src` for an asset shown large, as a picker's opened asset is: the 1080px size, chosen as
22
+ * `getThumbUrl` chooses. The host still takes `getFullSizeUrl`.
23
+ */
24
+ export declare function getPreviewUrl(asset: Parameters<typeof getThumbUrl>[0]): string | undefined;
25
+ /**
26
+ * The full-size image to hand to the host: the asset's `url`, or the largest of its `sizes`
27
+ * when it has no `url`, as with a search hit. Carries the content version when it has one.
28
+ */
29
+ export declare function getFullSizeUrl(asset: {
30
+ sizes?: string | null;
31
+ url?: string | null;
32
+ contentVersion?: number | null;
33
+ displaySizes?: string | null;
34
+ displayUrl?: string | null;
35
+ }): string | null;
36
+ /**
37
+ * Decoded blurhash placeholder for an asset, or null when absent/invalid. Paint this behind
38
+ * `getThumbUrl` (e.g. as a CSS background) for a progressive load.
39
+ */
40
+ export declare function getBlurhashPlaceholder(asset: {
41
+ blurhash?: string | null;
42
+ }): string | null;
@@ -0,0 +1,83 @@
1
+ import { blurhashToDataUrl } from './blurhash-placeholder.js';
2
+ import { parseSizes, pickSizeUrl } from './sizes.js';
3
+ // Two of the sizes Raster generates: a grid's thumbnail, and an image shown large.
4
+ const THUMB_TARGET_EDGE = 400;
5
+ const PREVIEW_TARGET_EDGE = 1080;
6
+ /**
7
+ * Append `?v=` so a cache holding this URL serves the current pixels. A promote replaces the
8
+ * file behind a stable URL, and `contentVersion` changes exactly when the bytes do. The CDN
9
+ * caches each `v` apart, so a value that changes more often, such as `updated`, would mint a
10
+ * cache entry per rename. An asset with no version keeps the bare URL others share.
11
+ */
12
+ export function versionedUrl(url, contentVersion) {
13
+ // The counter starts at 1, so 0 and anything non-finite can only be a corrupt value —
14
+ // render the bare URL rather than mint a junk cache entry per render.
15
+ if (typeof contentVersion !== 'number' ||
16
+ !Number.isFinite(contentVersion) ||
17
+ contentVersion <= 0) {
18
+ return url;
19
+ }
20
+ return `${url}${url.includes('?') ? '&' : '?'}v=${contentVersion}`;
21
+ }
22
+ /**
23
+ * The thumbnail `src` for an asset: a smaller size from `sizes`, or the full-size `url` until
24
+ * the sizes exist or when the source is smaller than that size, carrying the content version
25
+ * when the asset has one.
26
+ */
27
+ export function getThumbUrl(asset) {
28
+ return sizedUrl(asset, THUMB_TARGET_EDGE);
29
+ }
30
+ /**
31
+ * The `src` for an asset shown large, as a picker's opened asset is: the 1080px size, chosen as
32
+ * `getThumbUrl` chooses. The host still takes `getFullSizeUrl`.
33
+ */
34
+ export function getPreviewUrl(asset) {
35
+ return sizedUrl(asset, PREVIEW_TARGET_EDGE);
36
+ }
37
+ function sizedUrl(asset, targetEdge) {
38
+ // The override goes bare, as in `getFullSizeUrl`.
39
+ const display = sizedImage(asset.displayUrl, asset.displaySizes, targetEdge);
40
+ if (display !== null)
41
+ return display;
42
+ const own = sizedImage(asset.url, asset.sizes, targetEdge);
43
+ return own === null ? undefined : versionedUrl(own, asset.contentVersion);
44
+ }
45
+ /**
46
+ * The smallest size at or above `targetEdge`. Raster makes only sizes smaller than the source,
47
+ * so without one the source is no larger than the next size up: its `url` serves, or, for a
48
+ * search hit, which has no `url`, the largest size.
49
+ */
50
+ function sizedImage(url, sizes, targetEdge) {
51
+ const parsed = parseSizes(sizes);
52
+ if (Object.keys(parsed ?? {}).some((size) => Number(size) >= targetEdge)) {
53
+ return pickSizeUrl(parsed, targetEdge);
54
+ }
55
+ return url || pickSizeUrl(parsed, targetEdge);
56
+ }
57
+ /**
58
+ * The full-size image to hand to the host: the asset's `url`, or the largest of its `sizes`
59
+ * when it has no `url`, as with a search hit. Carries the content version when it has one.
60
+ */
61
+ export function getFullSizeUrl(asset) {
62
+ // The override is the promoted variant's own image, which the asset's version does not
63
+ // describe: that would pin a stale cache entry the bare URL avoids.
64
+ const display = largestImage(asset.displayUrl, asset.displaySizes);
65
+ if (display !== null)
66
+ return display;
67
+ const own = largestImage(asset.url, asset.sizes);
68
+ return own === null ? null : versionedUrl(own, asset.contentVersion);
69
+ }
70
+ function largestImage(url, sizes) {
71
+ if (typeof url === 'string' && url !== '')
72
+ return url;
73
+ // No size reaches Number.MAX_SAFE_INTEGER, so this always lands on the largest.
74
+ return pickSizeUrl(parseSizes(sizes), Number.MAX_SAFE_INTEGER);
75
+ }
76
+ /**
77
+ * Decoded blurhash placeholder for an asset, or null when absent/invalid. Paint this behind
78
+ * `getThumbUrl` (e.g. as a CSS background) for a progressive load.
79
+ */
80
+ export function getBlurhashPlaceholder(asset) {
81
+ return blurhashToDataUrl(asset.blurhash);
82
+ }
83
+ //# sourceMappingURL=image-src.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-src.js","sourceRoot":"","sources":["../../src/images/image-src.ts"],"names":[],"mappings":"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","sourcesContent":["import { blurhashToDataUrl } from './blurhash-placeholder.js'\nimport { parseSizes, pickSizeUrl } from './sizes.js'\n\n// Two of the sizes Raster generates: a grid's thumbnail, and an image shown large.\nconst THUMB_TARGET_EDGE = 400\nconst PREVIEW_TARGET_EDGE = 1080\n\n/**\n * Append `?v=` so a cache holding this URL serves the current pixels. A promote replaces the\n * file behind a stable URL, and `contentVersion` changes exactly when the bytes do. The CDN\n * caches each `v` apart, so a value that changes more often, such as `updated`, would mint a\n * cache entry per rename. An asset with no version keeps the bare URL others share.\n */\nexport function versionedUrl(url: string, contentVersion: number | null | undefined): string {\n\t// The counter starts at 1, so 0 and anything non-finite can only be a corrupt value —\n\t// render the bare URL rather than mint a junk cache entry per render.\n\tif (\n\t\ttypeof contentVersion !== 'number' ||\n\t\t!Number.isFinite(contentVersion) ||\n\t\tcontentVersion <= 0\n\t) {\n\t\treturn url\n\t}\n\treturn `${url}${url.includes('?') ? '&' : '?'}v=${contentVersion}`\n}\n\n/**\n * The thumbnail `src` for an asset: a smaller size from `sizes`, or the full-size `url` until\n * the sizes exist or when the source is smaller than that size, carrying the content version\n * when the asset has one.\n */\nexport function getThumbUrl(asset: {\n\tsizes?: string | null\n\turl?: string | null\n\tcontentVersion?: number | null\n\tdisplaySizes?: string | null\n\tdisplayUrl?: string | null\n}): string | undefined {\n\treturn sizedUrl(asset, THUMB_TARGET_EDGE)\n}\n\n/**\n * The `src` for an asset shown large, as a picker's opened asset is: the 1080px size, chosen as\n * `getThumbUrl` chooses. The host still takes `getFullSizeUrl`.\n */\nexport function getPreviewUrl(asset: Parameters<typeof getThumbUrl>[0]): string | undefined {\n\treturn sizedUrl(asset, PREVIEW_TARGET_EDGE)\n}\n\nfunction sizedUrl(\n\tasset: Parameters<typeof getThumbUrl>[0],\n\ttargetEdge: number\n): string | undefined {\n\t// The override goes bare, as in `getFullSizeUrl`.\n\tconst display = sizedImage(asset.displayUrl, asset.displaySizes, targetEdge)\n\tif (display !== null) return display\n\tconst own = sizedImage(asset.url, asset.sizes, targetEdge)\n\treturn own === null ? undefined : versionedUrl(own, asset.contentVersion)\n}\n\n/**\n * The smallest size at or above `targetEdge`. Raster makes only sizes smaller than the source,\n * so without one the source is no larger than the next size up: its `url` serves, or, for a\n * search hit, which has no `url`, the largest size.\n */\nfunction sizedImage(\n\turl: string | null | undefined,\n\tsizes: string | null | undefined,\n\ttargetEdge: number\n): string | null {\n\tconst parsed = parseSizes(sizes)\n\tif (Object.keys(parsed ?? {}).some((size) => Number(size) >= targetEdge)) {\n\t\treturn pickSizeUrl(parsed, targetEdge)\n\t}\n\treturn url || pickSizeUrl(parsed, targetEdge)\n}\n\n/**\n * The full-size image to hand to the host: the asset's `url`, or the largest of its `sizes`\n * when it has no `url`, as with a search hit. Carries the content version when it has one.\n */\nexport function getFullSizeUrl(asset: {\n\tsizes?: string | null\n\turl?: string | null\n\tcontentVersion?: number | null\n\tdisplaySizes?: string | null\n\tdisplayUrl?: string | null\n}): string | null {\n\t// The override is the promoted variant's own image, which the asset's version does not\n\t// describe: that would pin a stale cache entry the bare URL avoids.\n\tconst display = largestImage(asset.displayUrl, asset.displaySizes)\n\tif (display !== null) return display\n\tconst own = largestImage(asset.url, asset.sizes)\n\treturn own === null ? null : versionedUrl(own, asset.contentVersion)\n}\n\nfunction largestImage(\n\turl: string | null | undefined,\n\tsizes: string | null | undefined\n): string | null {\n\tif (typeof url === 'string' && url !== '') return url\n\t// No size reaches Number.MAX_SAFE_INTEGER, so this always lands on the largest.\n\treturn pickSizeUrl(parseSizes(sizes), Number.MAX_SAFE_INTEGER)\n}\n\n/**\n * Decoded blurhash placeholder for an asset, or null when absent/invalid. Paint this behind\n * `getThumbUrl` (e.g. as a CSS background) for a progressive load.\n */\nexport function getBlurhashPlaceholder(asset: { blurhash?: string | null }): string | null {\n\treturn blurhashToDataUrl(asset.blurhash)\n}\n"]}