@nohead/sdk 0.11.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ Changes to `@nohead/sdk` that you can notice. Versions follow
5
5
  releases; a change that could break your code is a major one. Each release's
6
6
  section is its GitHub release's notes.
7
7
 
8
+ ## 0.13.0
9
+
10
+ - `@nohead/sdk/astro`: `noheadLoader`, a loader for Astro's content
11
+ collections (Astro 5.9 or later). It loads a collection's published records
12
+ as entries, by record ID, with rich text as HTML, expanded assets in place
13
+ of their IDs and relations kept as IDs for `reference()`; `body` renders a
14
+ rich text field for `render(entry)`.
15
+ - **Breaking:** Astro's image service has its own entry point, which
16
+ astro.config names directly:
17
+ `image: { service: { entrypoint: "@nohead/sdk/astro/image-service" } }`.
18
+ `astroImageService` is gone from `@nohead/sdk/images`. The service now
19
+ builds on Astro's base service, so `<Image>` and `<Picture>` render `alt`,
20
+ `width`, `height`, `loading` and the `srcset` for `widths` and `densities`
21
+ (before, a bare `<img src>`). It serves WebP unless a `format` says
22
+ otherwise, and takes Astro's quality presets (`"high"`).
23
+ - Nuxt Image's `format: "jpg"` gives a JPEG, as `"jpeg"` does, instead of
24
+ the image's own format.
25
+ - `nextImageLoader` serves WebP, as Next.js's own optimizer would, where it
26
+ served each image in its own format (a JPEG at twice the size). A format
27
+ the URL names stays: `@svg` keeps an SVG a vector.
28
+ - **Breaking:** Nuxt Image (and the Astro image service) crop to a box of
29
+ both sizes when no `fit` is given, as their own services do. They used to
30
+ fit the image inside it, so an image of another shape came back smaller
31
+ and the browser stretched it to the `<img>`'s width and height.
32
+
33
+ ## 0.12.0
34
+
35
+ - `webhooks.create` and `webhooks.update` take `published_only`, and every
36
+ webhook has it: a published-only webhook sends only the events that change
37
+ what a published-only API key reads (a record published, unpublished or
38
+ edited while live, schema changes, changes to assets that published records
39
+ use), and skips drafts, schedules and edits to unpublished records.
40
+ - Audit events have `note`: the change note (`Nohead-Change-Note`) a record
41
+ or schema change was made with, or `null`.
42
+
8
43
  ## 0.11.0
9
44
 
10
45
  - `records.diff` takes `richText`, and the client's `richText` now applies to
package/README.md CHANGED
@@ -14,6 +14,8 @@ for await (const post of nohead.records.list("posts", {
14
14
  }
15
15
  ```
16
16
 
17
+ The key is a project API key: create one under **API keys** in the [web app](https://app.nohead.io), with the **Read records** scope for this example, and set it as `NOHEAD_API_KEY` in your app's environment (or pass it as `apiKey`, under [Configuration](#configuration)). The SDK doesn't read the key that `nohead login` saves for the CLI: give your app a key of its own.
18
+
17
19
  > **Status:** 0.x until the Nohead API launches.
18
20
 
19
21
  ## Contents
@@ -29,6 +31,7 @@ for await (const post of nohead.records.list("posts", {
29
31
  - [Concurrency](#concurrency)
30
32
  - [Assets](#assets)
31
33
  - [Search](#search)
34
+ - [Astro](#astro)
32
35
  - [Schema](#schema)
33
36
  - [Webhooks](#webhooks)
34
37
  - [Raw responses](#raw-responses)
@@ -55,20 +58,19 @@ const nohead = new Nohead({
55
58
  })
56
59
  ```
57
60
 
58
- | Option | Default | |
59
- | ------------ | ----------------------- | ----------------------------------------------------------------------------------------- |
60
- | `apiKey` | `NOHEAD_API_KEY` | A project API key (`nohead_sk_…`, or `nohead_pk_…` when published-only). Required. |
61
- | `baseUrl` | `https://api.nohead.io` | For a proxy, or a mock server in tests. |
62
- | `projectId` | the key's project | Looked up once with `GET /v1/me` when omitted. |
63
- | `maxRetries` | `2` | See [retries](#retries-and-idempotency). |
64
- | `timeout` | `60000` | Milliseconds an attempt may go without progress. See [retries](#retries-and-idempotency). |
65
- | `fetch` | `globalThis.fetch` | For proxies, instrumentation and tests. |
66
- | `headers` | `{}` | Added to every request. |
67
- | `warnings` | `true` | Logs deprecation and plan usage warnings, once each. |
68
- | `onRetry` | | Called before each retry. See [retries](#retries-and-idempotency). |
69
- | `richText` | `"document"` | How rich text reads: `"html"` or `"markdown"`. See [rich text](#rich-text). |
70
-
71
- An empty option counts as unset, as an empty environment variable does: `apiKey: ""` falls back to `NOHEAD_API_KEY`, and `baseUrl: ""` to `https://api.nohead.io`. The base URL comes only from `baseUrl`, never from the environment. A `baseUrl` that isn't an `http` or `https` URL throws.
61
+ | Option | Default | |
62
+ | ------------ | ------------------ | ----------------------------------------------------------------------------------------- |
63
+ | `apiKey` | `NOHEAD_API_KEY` | A project API key (`nohead_sk_…`, or `nohead_pk_…` when published-only). Required. |
64
+ | `projectId` | the key's project | Looked up once with `GET /v1/me` when omitted. |
65
+ | `maxRetries` | `2` | See [retries](#retries-and-idempotency). |
66
+ | `timeout` | `60000` | Milliseconds an attempt may go without progress. See [retries](#retries-and-idempotency). |
67
+ | `fetch` | `globalThis.fetch` | For proxies, instrumentation and tests. |
68
+ | `headers` | `{}` | Added to every request. |
69
+ | `warnings` | `true` | Logs deprecation and plan usage warnings, once each. |
70
+ | `onRetry` | | Called before each retry. See [retries](#retries-and-idempotency). |
71
+ | `richText` | `"document"` | How rich text reads: `"html"` or `"markdown"`. See [rich text](#rich-text). |
72
+
73
+ An empty option counts as unset, as an empty environment variable does: `apiKey: ""` falls back to `NOHEAD_API_KEY`.
72
74
 
73
75
  `withOptions` copies a client with another `timeout`, `maxRetries`, `onRetry` or `richText`, or more `headers` (added to the client's). The copy shares the key's project, so it doesn't look it up again:
74
76
 
@@ -223,7 +225,7 @@ Every error is a `NoheadError`. API errors are `APIError`s with `status`, `type`
223
225
 
224
226
  An API error's `message` names the status, the type and the request ID along with the API's message, so logging it is enough to look the request up: `422 validation_error: Title is required (request req_…)`. The API's own text is `apiMessage`, to show a person.
225
227
 
226
- The client follows no redirects: a `3xx` is an `APIError` (`type` `redirect`) that names where it pointed, which usually means the base URL is wrong.
228
+ The client follows no redirects: a `3xx` is an `APIError` (`type` `redirect`) that names where it pointed.
227
229
 
228
230
  Other errors: `ConnectionError`, `TimeoutError` (a `ConnectionError`), `AbortError` (your `signal` fired), `UploadError` and `WebhookVerificationError`.
229
231
 
@@ -325,8 +327,8 @@ imageUrl(asset.image_url!, { width: 400, dpr: 2, options: ["bl:2"] })
325
327
 
326
328
  Frameworks' image components, with `srcset` and all, take a loader from it:
327
329
 
328
- - **Next.js:** a file named by `images.loaderFile` in next.config: `export { nextImageLoader as default } from "@nohead/sdk/images"`, then `<Image src={asset.image_url} … />`.
329
- - **Astro:** a module named by `image.service.entrypoint` in astro.config: `export { astroImageService as default } from "@nohead/sdk/images"`.
330
+ - **Next.js:** a file named by `images.loaderFile` in next.config: `export { nextImageLoader as default } from "@nohead/sdk/images"`, then `<Image src={asset.image_url} … />`. It serves WebP, as Next.js's own optimizer does by default, unless the URL names a format (`@avif` for AVIF, which `images.formats` can't ask a loader for): ``src={`${asset.image_url}@svg`}`` keeps an SVG a vector.
331
+ - **Astro:** `image: { service: { entrypoint: "@nohead/sdk/astro/image-service" } }` in astro.config, then `<Image src={asset.image_url} width={…} height={…} alt={…} />`. It serves WebP unless a `format` says otherwise: give an SVG `format="svg"` to keep it a vector (an asset's `content_type` tells). Pass `width` and `height`, which expanded assets carry; `inferSize` would need the image host in Astro's `image.domains`. Needs Astro 5.9 or later.
330
332
  - **Nuxt Image:** a custom provider file in nuxt.config's `image.providers`: `export { nuxtImageProvider as default } from "@nohead/sdk/images"` (Nuxt Image 1: `export { getImage } from "@nohead/sdk/images"`).
331
333
 
332
334
  The loaders leave other images, the site's own, as they are.
@@ -376,6 +378,75 @@ results.meta.total_estimate
376
378
 
377
379
  Search needs the `search:read` scope and covers every collection the key can read, matching their `searchable` fields. It pages through the first 1,000 hits. For search straight from a site's browser, without the API, use a search token from the web app with Meilisearch's own clients or InstantSearch.
378
380
 
381
+ ## Astro
382
+
383
+ `@nohead/sdk/astro` loads collections into Astro's content collections (Astro 5.9 or later), so pages read them with `getCollection`, `getEntry`, `reference()` and `render()`:
384
+
385
+ ```ts
386
+ // src/content.config.ts
387
+ import { defineCollection, reference } from "astro:content"
388
+ import { z } from "astro/zod"
389
+ import { noheadLoader } from "@nohead/sdk/astro"
390
+
391
+ // Astro reads .env into import.meta.env, not process.env.
392
+ const apiKey = import.meta.env.NOHEAD_API_KEY
393
+ const image = z.object({
394
+ image_url: z.string(),
395
+ width: z.number(),
396
+ height: z.number(),
397
+ alt: z.string().optional(),
398
+ })
399
+
400
+ export const collections = {
401
+ people: defineCollection({
402
+ loader: noheadLoader({ collection: "people", apiKey }),
403
+ schema: z.object({ name: z.string(), slug: z.string() }),
404
+ }),
405
+ posts: defineCollection({
406
+ loader: noheadLoader({
407
+ collection: "posts",
408
+ expand: ["cover"],
409
+ body: "body",
410
+ apiKey,
411
+ }),
412
+ schema: z.object({
413
+ title: z.string(),
414
+ slug: z.string(),
415
+ date: z.coerce.date(),
416
+ authors: z.array(reference("people")),
417
+ cover: image.optional(),
418
+ }),
419
+ }),
420
+ }
421
+ ```
422
+
423
+ ```astro
424
+ ---
425
+ // src/pages/posts/[slug].astro
426
+ import { getCollection, render } from "astro:content"
427
+ import { Image } from "astro:assets"
428
+
429
+ export async function getStaticPaths() {
430
+ const posts = await getCollection("posts")
431
+ return posts.map((post) => ({ params: { slug: post.data.slug }, props: { post } }))
432
+ }
433
+ const { post } = Astro.props
434
+ const { Content } = await render(post)
435
+ const { cover } = post.data
436
+ ---
437
+ {cover && <Image src={cover.image_url} width={cover.width} height={cover.height} alt={cover.alt ?? ""} />}
438
+ <Content />
439
+ ```
440
+
441
+ - **Entries.** Each record becomes an entry whose `id` is the record ID and whose `data` is its fields, checked by the collection's `schema`. Collections load when the site builds and when `astro dev` starts. Each load lists the whole collection, removes the entries of records that are gone, and skips records that haven't changed.
442
+ - **Published only.** Only published records load, whatever the key may read, so a published-only key (`nohead_pk_…`) is enough. `filter` adds conditions (`{ featured: true }`); `filter: { published_at: undefined }` loads every record.
443
+ - **Rich text** reads as HTML (or Markdown with `richText: "markdown"`). `body` names the field that `render(entry)` renders: it moves out of `data` into the entry's rendered content. As Markdown, it goes through Astro's own Markdown pipeline (your plugins, syntax highlighting, `headings`). A record can have several rich text fields and an entry renders one; the others stay in `data` as HTML, for `<Fragment set:html={post.data.summary} />`.
444
+ - **Order.** Astro doesn't keep an order for a collection (from Astro 6, `getCollection` returns entries by ID), so sort what it returns: `posts.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())`.
445
+ - **Relations** stay record IDs, for `reference("people")` and `getEntry`.
446
+ - **Assets** named in `expand` replace their ID with the asset: `id`, `filename`, `content_type`, and when they have them `alt`, `width`, `height`, `image_url`, `file_url` and `media_url` (`EmbeddedAsset`). With the image service, `@nohead/sdk/astro/image-service` (under [Assets](#assets)), `<Image>` writes sizes and formats into the image's URL. An expanded relation becomes `{ id, data }`. An asset or record deleted since is left out, and so is an unpublished record, unless `filter` loads unpublished entries too.
447
+ - **Empty fields** are left out of `data` rather than `null`, so `.optional()` fits them. Dates and times are strings: `z.coerce.date()` makes them `Date`s.
448
+ - **The key** is `apiKey`, or `NOHEAD_API_KEY` from the environment; the other client options (`timeout`, `maxRetries`, `headers`) apply too. Astro keeps `.env` in `import.meta.env`, so pass the key from there as above.
449
+
379
450
  ## Schema
380
451
 
381
452
  ```ts
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Astro's image service for Nohead images
3
+ * (`@nohead/sdk/astro/image-service`). In astro.config:
4
+ *
5
+ * image: { service: { entrypoint: "@nohead/sdk/astro/image-service" } }
6
+ *
7
+ * then `<Image src={asset.image_url} width={…} height={…} alt={…} />`.
8
+ * `<Image>` and `<Picture>` write each size and format into the image's
9
+ * URL, and Nohead makes it on request, so the build never downloads or
10
+ * resizes an image. Astro's base service supplies the rest (the `<img>`
11
+ * attributes, `srcset` for `widths` and `densities`). WebP unless a
12
+ * `format` says otherwise. Other images (the site's own, imported or
13
+ * remote) are served as they are.
14
+ */
15
+ import type { ExternalImageService } from "astro";
16
+ declare const imageService: ExternalImageService;
17
+ export default imageService;
@@ -0,0 +1,28 @@
1
+ import { baseService } from "astro/assets";
2
+ import { fitOf, formatOf, qualityOf } from "./frameworks.js";
3
+ import { imageUrl, isImageUrl } from "./images.js";
4
+ const imageService = {
5
+ getHTMLAttributes: baseService.getHTMLAttributes,
6
+ getSrcSet: baseService.getSrcSet,
7
+ async validateOptions(options, imageConfig, logger) {
8
+ const validated = await baseService.validateOptions(options, imageConfig, logger);
9
+ // So the srcset's <source type> says what the URLs serve.
10
+ if (isImageUrl(validated.src))
11
+ validated.format ??= "webp";
12
+ return validated;
13
+ },
14
+ getURL(options) {
15
+ const { src } = options;
16
+ // An imported image's metadata, or another URL.
17
+ if (!isImageUrl(src))
18
+ return typeof src === "string" ? src : src.src;
19
+ return imageUrl(src, {
20
+ width: options.width,
21
+ height: options.height,
22
+ fit: fitOf(options.fit, options.width, options.height),
23
+ format: formatOf(options.format ?? "webp"),
24
+ quality: qualityOf(options.quality),
25
+ });
26
+ },
27
+ };
28
+ export default imageService;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Astro's content collections from Nohead (`@nohead/sdk/astro`). In
3
+ * src/content.config.ts:
4
+ *
5
+ * import { defineCollection } from "astro:content"
6
+ * import { noheadLoader } from "@nohead/sdk/astro"
7
+ *
8
+ * export const collections = {
9
+ * posts: defineCollection({
10
+ * loader: noheadLoader({ collection: "posts", expand: ["cover"] }),
11
+ * }),
12
+ * }
13
+ *
14
+ * Every published record becomes an entry, by its record ID, when the site
15
+ * builds (and when `astro dev` starts or syncs).
16
+ */
17
+ import type { Loader } from "astro/loaders";
18
+ import { type ClientOptions } from "./core.ts";
19
+ import type * as G from "./generated/types.gen.ts";
20
+ import type { RecordFilter } from "./types.ts";
21
+ export interface NoheadLoaderOptions extends ClientOptions {
22
+ /** The collection's slug or ID. */
23
+ collection: string;
24
+ /**
25
+ * Filters, added to the default `{ published_at: { exists: true } }`, so
26
+ * only published records load whatever the key may read. Set
27
+ * `published_at` to change that: `undefined` loads every record.
28
+ */
29
+ filter?: RecordFilter;
30
+ /**
31
+ * Asset (or relation) fields whose IDs to replace with what they point
32
+ * at: an asset as an `EmbeddedAsset`, a published record as an
33
+ * `EmbeddedRecord`. At most 5. Relations otherwise stay record IDs, for
34
+ * `reference()`.
35
+ */
36
+ expand?: string[];
37
+ /**
38
+ * A rich text field to render: it moves from `data` into the entry's
39
+ * `rendered`, so `render(entry)` returns its `<Content />`. As HTML (the
40
+ * default), or with `richText: "markdown"`, through Astro's Markdown
41
+ * pipeline (your remark and rehype plugins, syntax highlighting,
42
+ * headings).
43
+ */
44
+ body?: string;
45
+ /**
46
+ * How rich text fields read: `"html"` by default, strings ready for
47
+ * `set:html`, or `"markdown"`. The `"document"` form is the API's.
48
+ */
49
+ richText?: G.RichText;
50
+ }
51
+ /** An expanded asset field's value: what a page needs to show the file. */
52
+ export interface EmbeddedAsset {
53
+ id: string;
54
+ filename: string;
55
+ content_type: string;
56
+ /** The asset's alt text, when it has some. */
57
+ alt?: string;
58
+ /** For images: their size, and their URL (see `@nohead/sdk/images`). */
59
+ width?: number;
60
+ height?: number;
61
+ image_url?: string;
62
+ /** The file as uploaded, for any ready asset. */
63
+ file_url?: string;
64
+ /** For video and audio: the URL that plays inline. */
65
+ media_url?: string;
66
+ }
67
+ /** An expanded relation field's value: the related record's ID and fields. */
68
+ export interface EmbeddedRecord {
69
+ id: string;
70
+ data: Record<string, unknown>;
71
+ }
72
+ /**
73
+ * An Astro content loader for a Nohead collection. It reads the key from
74
+ * `apiKey` or `NOHEAD_API_KEY`, and takes the other client options too
75
+ * (`timeout`, `maxRetries`, `headers`…). Empty fields are left out of an
76
+ * entry's data, so a schema's `.optional()` fits them.
77
+ */
78
+ export declare function noheadLoader(options: NoheadLoaderOptions): Loader;
package/dist/astro.js ADDED
@@ -0,0 +1,118 @@
1
+ import { Nohead } from "./client.js";
2
+ import { environment } from "./core.js";
3
+ import { NoheadError } from "./errors.js";
4
+ /**
5
+ * An Astro content loader for a Nohead collection. It reads the key from
6
+ * `apiKey` or `NOHEAD_API_KEY`, and takes the other client options too
7
+ * (`timeout`, `maxRetries`, `headers`…). Empty fields are left out of an
8
+ * entry's data, so a schema's `.optional()` fits them.
9
+ */
10
+ export function noheadLoader(options) {
11
+ const { collection, filter, expand, body, richText = "html", ...client } = options;
12
+ if (!collection)
13
+ throw new TypeError("noheadLoader needs a collection");
14
+ if (body && richText === "document") {
15
+ throw new TypeError('noheadLoader renders its body from HTML or Markdown: set richText to "html" or "markdown"');
16
+ }
17
+ return {
18
+ name: "nohead",
19
+ async load(context) {
20
+ const { store, logger } = context;
21
+ // Astro reads .env into import.meta.env, not process.env.
22
+ if (!client.apiKey && !environment().NOHEAD_API_KEY) {
23
+ throw new NoheadError("Missing API key: pass `apiKey: import.meta.env.NOHEAD_API_KEY` to noheadLoader, or set NOHEAD_API_KEY in the environment");
24
+ }
25
+ const nohead = new Nohead({ ...client, richText });
26
+ const records = [];
27
+ for await (const record of nohead.records.list(collection, {
28
+ limit: 100,
29
+ filter: { published_at: { exists: true }, ...filter },
30
+ expand,
31
+ })) {
32
+ records.push(record);
33
+ }
34
+ // Embedded records are published too, unless entries may not be.
35
+ const publishedAt = filter && "published_at" in filter
36
+ ? filter.published_at
37
+ : { exists: true };
38
+ const publishedOnly = !(publishedAt === undefined || publishedAt.exists === false);
39
+ // Every entry is made before the store changes, so a failure leaves
40
+ // the entries as they were.
41
+ const entries = await Promise.all(records.map(async (record) => {
42
+ const data = entryData(record, publishedOnly);
43
+ const digest = context.generateDigest(data);
44
+ if (store.get(record.id)?.digest === digest)
45
+ return undefined;
46
+ let rendered;
47
+ if (body !== undefined) {
48
+ const value = data[body];
49
+ delete data[body];
50
+ if (typeof value === "string") {
51
+ rendered =
52
+ richText === "markdown"
53
+ ? await context.renderMarkdown(value)
54
+ : { html: value };
55
+ }
56
+ }
57
+ const parsed = await context.parseData({ id: record.id, data });
58
+ return { id: record.id, data: parsed, digest, rendered };
59
+ }));
60
+ const ids = new Set(records.map((record) => record.id));
61
+ for (const id of store.keys())
62
+ if (!ids.has(id))
63
+ store.delete(id);
64
+ for (const entry of entries)
65
+ if (entry)
66
+ store.set(entry);
67
+ logger.info(`Loaded ${records.length} records from ${collection}`);
68
+ },
69
+ };
70
+ }
71
+ /**
72
+ * A record's fields as an entry's data: without empty values, and with each
73
+ * expanded asset or relation in place of its ID.
74
+ */
75
+ function entryData(record, publishedOnly) {
76
+ const data = present(record.data);
77
+ // Gone since, or (as entries are) unpublished: left out.
78
+ const shown = (embedded) => embedded !== null &&
79
+ !(publishedOnly && embedded.object === "record" && !embedded.published_at);
80
+ for (const [key, embedded] of Object.entries(record.expanded ?? {})) {
81
+ const value = record.data[key];
82
+ // A rich text field's expansion (the assets its images show) is a list
83
+ // beside a single value: the field keeps its text.
84
+ if (Array.isArray(value) && Array.isArray(embedded)) {
85
+ data[key] = embedded.filter(shown).map(entryValue);
86
+ }
87
+ else if (typeof value === "string" && !Array.isArray(embedded)) {
88
+ if (shown(embedded))
89
+ data[key] = entryValue(embedded);
90
+ else
91
+ delete data[key];
92
+ }
93
+ }
94
+ return data;
95
+ }
96
+ function entryValue(embedded) {
97
+ if (embedded.object === "record") {
98
+ return { id: embedded.id, data: present(embedded.data) };
99
+ }
100
+ const { id, filename, content_type } = embedded;
101
+ return {
102
+ id,
103
+ filename,
104
+ content_type,
105
+ ...present({
106
+ alt: embedded.alt,
107
+ width: embedded.width,
108
+ height: embedded.height,
109
+ image_url: embedded.image_url,
110
+ file_url: embedded.file_url,
111
+ media_url: embedded.media_url,
112
+ }),
113
+ };
114
+ }
115
+ /** The values that are set: Zod's `.optional()` takes undefined, not null. */
116
+ function present(values) {
117
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== null && value !== undefined));
118
+ }
package/dist/core.d.ts CHANGED
@@ -149,4 +149,5 @@ export declare class Core {
149
149
  * ISO 8601.
150
150
  */
151
151
  export declare function appendQuery(params: URLSearchParams, value: unknown, key?: string): void;
152
+ export declare function environment(): Record<string, string | undefined>;
152
153
  export {};
package/dist/core.js CHANGED
@@ -436,7 +436,7 @@ function finiteNumbers(key, value) {
436
436
  }
437
437
  return value;
438
438
  }
439
- function environment() {
439
+ export function environment() {
440
440
  const process = globalThis.process;
441
441
  return process?.env ?? {};
442
442
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The fit asked for (an unknown one as `contain`, as Astro reads it), else
3
+ * `cover` for a box of both sizes, as Astro's and Nuxt Image's own services
4
+ * crop to it: an image fitted inside would be stretched to the <img>'s width
5
+ * and height.
6
+ */
7
+ export declare const fitOf: (fit: unknown, width?: number, height?: number) => "fill" | "contain" | "cover" | undefined;
8
+ /** Any other format (an SVG's `svg`) leaves the image in its own. */
9
+ export declare const formatOf: (format: unknown) => "webp" | "avif" | "jpeg" | "png" | undefined;
10
+ export declare const numberOf: (value: unknown) => number | undefined;
11
+ export declare const qualityOf: (quality: unknown) => number | undefined;
@@ -0,0 +1,48 @@
1
+ const FORMATS = {
2
+ webp: "webp",
3
+ avif: "avif",
4
+ jpeg: "jpeg",
5
+ jpg: "jpeg",
6
+ png: "png",
7
+ };
8
+ // Astro's and Nuxt Image's fits, as imgproxy has them.
9
+ const FITS = {
10
+ cover: "cover",
11
+ contain: "contain",
12
+ fill: "fill",
13
+ inside: "contain",
14
+ outside: "cover",
15
+ "scale-down": "contain",
16
+ };
17
+ // Astro's quality presets, as its own services read them.
18
+ const QUALITIES = {
19
+ low: 25,
20
+ mid: 50,
21
+ high: 80,
22
+ max: 100,
23
+ };
24
+ /**
25
+ * The fit asked for (an unknown one as `contain`, as Astro reads it), else
26
+ * `cover` for a box of both sizes, as Astro's and Nuxt Image's own services
27
+ * crop to it: an image fitted inside would be stretched to the <img>'s width
28
+ * and height.
29
+ */
30
+ export const fitOf = (fit, width, height) => typeof fit === "string" && fit
31
+ ? Object.hasOwn(FITS, fit)
32
+ ? FITS[fit]
33
+ : "contain"
34
+ : width && height
35
+ ? "cover"
36
+ : undefined;
37
+ /** Any other format (an SVG's `svg`) leaves the image in its own. */
38
+ export const formatOf = (format) => typeof format === "string" && Object.hasOwn(FORMATS, format)
39
+ ? FORMATS[format]
40
+ : undefined;
41
+ export const numberOf = (value) => typeof value === "number"
42
+ ? value
43
+ : typeof value === "string" && value
44
+ ? Number(value)
45
+ : undefined;
46
+ export const qualityOf = (quality) => typeof quality === "string" && Object.hasOwn(QUALITIES, quality)
47
+ ? QUALITIES[quality]
48
+ : numberOf(quality);
@@ -1231,6 +1231,10 @@ export type WebhookProperties = {
1231
1231
  * The subscribed event types and wildcards (`record.*`, or `*` for all).
1232
1232
  */
1233
1233
  event_types: Array<WebhookSubscription>;
1234
+ /**
1235
+ * Send only events that change what a published-only API key can read: a record's change while it is published (or that publishes, unpublishes, deletes or restores it), schema changes, and changes to assets that published records use. Drafts, schedules, history redactions, uploads and purges are never sent. `webhook.test` always is.
1236
+ */
1237
+ published_only: boolean;
1234
1238
  enabled: boolean;
1235
1239
  /**
1236
1240
  * When the webhook was deleted; null unless it is.
@@ -1336,10 +1340,19 @@ export type AuditEvent = {
1336
1340
  * The client the change came through, when known.
1337
1341
  */
1338
1342
  via: null | Via;
1343
+ /**
1344
+ * The change note (`Nohead-Change-Note`) the change was made with,
1345
+ * read from the revision or schema change the event references, so
1346
+ * record and schema events have one when their change did; null
1347
+ * otherwise, and once a revision's history is pruned.
1348
+ *
1349
+ */
1350
+ note: string | null;
1339
1351
  /**
1340
1352
  * Small, action-specific references, e.g. `revision_id` and
1341
1353
  * `collection_id` for record events, `schema_change_id` for schema
1342
- * events, `changed` attribute names for updates. Never content.
1354
+ * events, `changed` attribute names for updates (field keys for a
1355
+ * draft's). Never content.
1343
1356
  *
1344
1357
  */
1345
1358
  metadata: {
@@ -1363,12 +1376,20 @@ export type WebhookCreate = {
1363
1376
  url: string;
1364
1377
  description?: string | null;
1365
1378
  event_types: Array<WebhookSubscription>;
1379
+ /**
1380
+ * Send only events that change what a published-only API key can read (`false` by default). It can't be combined with event types it never sends, such as `draft.*`.
1381
+ */
1382
+ published_only?: boolean;
1366
1383
  enabled?: boolean;
1367
1384
  };
1368
1385
  export type WebhookUpdate = {
1369
1386
  url?: string;
1370
1387
  description?: string | null;
1371
1388
  event_types?: Array<WebhookSubscription>;
1389
+ /**
1390
+ * Send only events that change what a published-only API key can read (`false` by default). It can't be combined with event types it never sends, such as `draft.*`.
1391
+ */
1392
+ published_only?: boolean;
1372
1393
  enabled?: boolean;
1373
1394
  };
1374
1395
  export type WebhookDeliveryProperties = {
package/dist/images.d.ts CHANGED
@@ -1,12 +1,3 @@
1
- /**
2
- * Image renditions without a request (`@nohead/sdk/images`). Image URLs are
3
- * open: an image asset's `image_url` serves the whole image, and any of
4
- * imgproxy's processing options written into it transform it
5
- * (https://docs.imgproxy.net/usage/processing). No dependencies, so it suits
6
- * browser bundles and frameworks' image components.
7
- *
8
- * imageUrl(asset, { width: 800, height: 600, fit: "cover", format: "webp" })
9
- */
10
1
  export interface ImageOptions {
11
2
  width?: number;
12
3
  height?: number;
@@ -34,6 +25,8 @@ export declare function isImageUrl(src: unknown): src is string;
34
25
  * A Next.js image loader. In a file named by `images.loaderFile` in
35
26
  * next.config: `export { nextImageLoader as default } from "@nohead/sdk/images"`,
36
27
  * then `<Image src={asset.image_url} width={…} height={…} alt={asset.alt} />`.
28
+ * As WebP, which Next.js's own optimizer would pick, unless the URL names a
29
+ * format: `${asset.image_url}@png`, or `@svg` to keep an SVG a vector.
37
30
  * Other images (the site's own) are left as they are.
38
31
  */
39
32
  export declare function nextImageLoader({ src, width, quality, }: {
@@ -41,22 +34,6 @@ export declare function nextImageLoader({ src, width, quality, }: {
41
34
  width: number;
42
35
  quality?: number;
43
36
  }): string;
44
- /**
45
- * An Astro image service (an external one: Astro never fetches the image).
46
- * In a module named by `image.service.entrypoint` in astro.config:
47
- * `export { astroImageService as default } from "@nohead/sdk/images"`.
48
- * Other images (the site's own, imported or remote) are served as they are.
49
- */
50
- export declare const astroImageService: {
51
- getURL(options: {
52
- src: unknown;
53
- width?: number;
54
- height?: number;
55
- format?: string;
56
- quality?: number | string;
57
- fit?: string;
58
- }): string;
59
- };
60
37
  /**
61
38
  * Nuxt Image's custom provider (`getImage`). In a file named as a provider
62
39
  * in nuxt.config's `image.providers`: on Nuxt Image 2,
package/dist/images.js CHANGED
@@ -3,11 +3,15 @@
3
3
  * open: an image asset's `image_url` serves the whole image, and any of
4
4
  * imgproxy's processing options written into it transform it
5
5
  * (https://docs.imgproxy.net/usage/processing). No dependencies, so it suits
6
- * browser bundles and frameworks' image components.
6
+ * browser bundles and frameworks' image components (Astro's image service is
7
+ * `@nohead/sdk/astro/image-service`).
7
8
  *
8
9
  * imageUrl(asset, { width: 800, height: 600, fit: "cover", format: "webp" })
9
10
  */
11
+ import { fitOf, formatOf, numberOf } from "./frameworks.js";
10
12
  const FITS = { contain: "fit", cover: "fill", fill: "force" };
13
+ // The format an image URL names, after its source: `…@webp`.
14
+ const FORMAT = /@[a-z]+$/;
11
15
  /** A rendition of an image: its `image_url` with these options. */
12
16
  export function imageUrl(image, options = {}) {
13
17
  const base = typeof image === "string" ? image : image.image_url;
@@ -38,7 +42,7 @@ export function imageUrl(image, options = {}) {
38
42
  // The source comes last; a format replaces any the URL already names.
39
43
  let source = base.slice(at);
40
44
  if (format)
41
- source = `${source.replace(/@[a-z]+$/, "")}@${format}`;
45
+ source = `${source.replace(FORMAT, "")}@${format}`;
42
46
  return `${base.slice(0, at)}${segments.map((s) => `/${s}`).join("")}${source}`;
43
47
  }
44
48
  /** Whether a URL is a Nohead image's: the loaders leave any other alone. */
@@ -49,46 +53,17 @@ export function isImageUrl(src) {
49
53
  * A Next.js image loader. In a file named by `images.loaderFile` in
50
54
  * next.config: `export { nextImageLoader as default } from "@nohead/sdk/images"`,
51
55
  * then `<Image src={asset.image_url} width={…} height={…} alt={asset.alt} />`.
56
+ * As WebP, which Next.js's own optimizer would pick, unless the URL names a
57
+ * format: `${asset.image_url}@png`, or `@svg` to keep an SVG a vector.
52
58
  * Other images (the site's own) are left as they are.
53
59
  */
54
60
  export function nextImageLoader({ src, width, quality, }) {
55
- return isImageUrl(src) ? imageUrl(src, { width, quality }) : src;
61
+ if (!isImageUrl(src))
62
+ return src;
63
+ // A loader is given no format: one the URL names stays.
64
+ const format = FORMAT.test(src) ? undefined : "webp";
65
+ return imageUrl(src, { width, quality, format });
56
66
  }
57
- const FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
58
- // Astro's and Nuxt Image's fits, as imgproxy has them.
59
- const FRAMEWORK_FITS = {
60
- cover: "cover",
61
- contain: "contain",
62
- fill: "fill",
63
- inside: "contain",
64
- outside: "cover",
65
- };
66
- const fitOf = (fit) => typeof fit === "string" ? FRAMEWORK_FITS[fit] : undefined;
67
- const formatOf = (format) => typeof format === "string" && FORMATS.has(format)
68
- ? format
69
- : undefined;
70
- /**
71
- * An Astro image service (an external one: Astro never fetches the image).
72
- * In a module named by `image.service.entrypoint` in astro.config:
73
- * `export { astroImageService as default } from "@nohead/sdk/images"`.
74
- * Other images (the site's own, imported or remote) are served as they are.
75
- */
76
- export const astroImageService = {
77
- getURL(options) {
78
- const { src } = options;
79
- if (!isImageUrl(src)) {
80
- const own = typeof src === "string" ? src : src?.src;
81
- return typeof own === "string" ? own : "";
82
- }
83
- return imageUrl(src, {
84
- width: options.width,
85
- height: options.height,
86
- fit: fitOf(options.fit),
87
- format: formatOf(options.format),
88
- quality: typeof options.quality === "number" ? options.quality : undefined,
89
- });
90
- },
91
- };
92
67
  /**
93
68
  * Nuxt Image's custom provider (`getImage`). In a file named as a provider
94
69
  * in nuxt.config's `image.providers`: on Nuxt Image 2,
@@ -97,20 +72,17 @@ export const astroImageService = {
97
72
  * images are left as they are.
98
73
  */
99
74
  export function getImage(src, { modifiers = {} } = {}) {
100
- const number = (value) => typeof value === "number"
101
- ? value
102
- : typeof value === "string" && value
103
- ? Number(value)
104
- : undefined;
105
75
  if (!isImageUrl(src))
106
76
  return { url: src };
77
+ const width = numberOf(modifiers.width);
78
+ const height = numberOf(modifiers.height);
107
79
  return {
108
80
  url: imageUrl(src, {
109
- width: number(modifiers.width),
110
- height: number(modifiers.height),
111
- fit: fitOf(modifiers.fit),
81
+ width,
82
+ height,
83
+ fit: fitOf(modifiers.fit, width, height),
112
84
  format: formatOf(modifiers.format),
113
- quality: number(modifiers.quality),
85
+ quality: numberOf(modifiers.quality),
114
86
  }),
115
87
  };
116
88
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.11.0";
1
+ export declare const VERSION = "0.13.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.11.0";
1
+ export const VERSION = "0.13.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nohead/sdk",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Official TypeScript SDK for the Nohead API",
5
5
  "license": "MIT",
6
6
  "homepage": "https://nohead.io",
@@ -31,6 +31,14 @@
31
31
  "types": "./dist/images.d.ts",
32
32
  "default": "./dist/images.js"
33
33
  },
34
+ "./astro": {
35
+ "types": "./dist/astro.d.ts",
36
+ "default": "./dist/astro.js"
37
+ },
38
+ "./astro/image-service": {
39
+ "types": "./dist/astro-image-service.d.ts",
40
+ "default": "./dist/astro-image-service.js"
41
+ },
34
42
  "./package.json": "./package.json"
35
43
  },
36
44
  "files": [
@@ -53,11 +61,20 @@
53
61
  "samples": "node scripts/samples.ts",
54
62
  "typecheck:smoke": "tsc -p smoke"
55
63
  },
64
+ "peerDependencies": {
65
+ "astro": ">=5.9.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "astro": {
69
+ "optional": true
70
+ }
71
+ },
56
72
  "devDependencies": {
57
73
  "@eslint/js": "^10.0.1",
58
74
  "@hey-api/openapi-ts": "0.99.0",
59
75
  "@types/node": "^26.6.4",
60
76
  "ajv": "^8.20.0",
77
+ "astro": "^7.4.7",
61
78
  "eslint": "^10.12.0",
62
79
  "globals": "^17.13.0",
63
80
  "prettier": "^3.9.9",