@nohead/sdk 0.12.0 → 0.14.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 +38 -0
- package/README.md +100 -17
- package/dist/astro-image-service.d.ts +29 -0
- package/dist/astro-image-service.js +32 -0
- package/dist/astro-schema.d.ts +20 -0
- package/dist/astro-schema.js +125 -0
- package/dist/astro.d.ts +79 -0
- package/dist/astro.js +153 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +1 -1
- package/dist/frameworks.d.ts +11 -0
- package/dist/frameworks.js +48 -0
- package/dist/images.d.ts +2 -25
- package/dist/images.js +19 -47
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ 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.14.0
|
|
9
|
+
|
|
10
|
+
- `noheadLoader` gives each collection its schema and TypeScript type, from
|
|
11
|
+
the collection's fields in Nohead (Astro 6 or later, through its
|
|
12
|
+
`createSchema`), so `defineCollection({ loader: noheadLoader(…) })` is
|
|
13
|
+
typed without a hand-written Zod schema. Relations are `reference()`s to
|
|
14
|
+
the collection named like their target's slug, dates are `Date`s, and
|
|
15
|
+
expanded assets and relations are `EmbeddedAsset`s and `EmbeddedRecord`s.
|
|
16
|
+
It takes the key's `schema:read` scope. A `schema` given to
|
|
17
|
+
`defineCollection` still takes its place.
|
|
18
|
+
- The Astro image service takes defaults for every image from its `config`
|
|
19
|
+
in astro.config: `quality` and `format`. An image's own options win.
|
|
20
|
+
|
|
21
|
+
## 0.13.0
|
|
22
|
+
|
|
23
|
+
- `@nohead/sdk/astro`: `noheadLoader`, a loader for Astro's content
|
|
24
|
+
collections (Astro 5.9 or later). It loads a collection's published records
|
|
25
|
+
as entries, by record ID, with rich text as HTML, expanded assets in place
|
|
26
|
+
of their IDs and relations kept as IDs for `reference()`; `body` renders a
|
|
27
|
+
rich text field for `render(entry)`.
|
|
28
|
+
- **Breaking:** Astro's image service has its own entry point, which
|
|
29
|
+
astro.config names directly:
|
|
30
|
+
`image: { service: { entrypoint: "@nohead/sdk/astro/image-service" } }`.
|
|
31
|
+
`astroImageService` is gone from `@nohead/sdk/images`. The service now
|
|
32
|
+
builds on Astro's base service, so `<Image>` and `<Picture>` render `alt`,
|
|
33
|
+
`width`, `height`, `loading` and the `srcset` for `widths` and `densities`
|
|
34
|
+
(before, a bare `<img src>`). It serves WebP unless a `format` says
|
|
35
|
+
otherwise, and takes Astro's quality presets (`"high"`).
|
|
36
|
+
- Nuxt Image's `format: "jpg"` gives a JPEG, as `"jpeg"` does, instead of
|
|
37
|
+
the image's own format.
|
|
38
|
+
- `nextImageLoader` serves WebP, as Next.js's own optimizer would, where it
|
|
39
|
+
served each image in its own format (a JPEG at twice the size). A format
|
|
40
|
+
the URL names stays: `@svg` keeps an SVG a vector.
|
|
41
|
+
- **Breaking:** Nuxt Image (and the Astro image service) crop to a box of
|
|
42
|
+
both sizes when no `fit` is given, as their own services do. They used to
|
|
43
|
+
fit the image inside it, so an image of another shape came back smaller
|
|
44
|
+
and the browser stretched it to the `<img>`'s width and height.
|
|
45
|
+
|
|
8
46
|
## 0.12.0
|
|
9
47
|
|
|
10
48
|
- `webhooks.create` and `webhooks.update` take `published_only`, and every
|
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ The key is a project API key: create one under **API keys** in the [web app](htt
|
|
|
31
31
|
- [Concurrency](#concurrency)
|
|
32
32
|
- [Assets](#assets)
|
|
33
33
|
- [Search](#search)
|
|
34
|
+
- [Astro](#astro)
|
|
34
35
|
- [Schema](#schema)
|
|
35
36
|
- [Webhooks](#webhooks)
|
|
36
37
|
- [Raw responses](#raw-responses)
|
|
@@ -57,20 +58,19 @@ const nohead = new Nohead({
|
|
|
57
58
|
})
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
| Option | Default
|
|
61
|
-
| ------------ |
|
|
62
|
-
| `apiKey` | `NOHEAD_API_KEY`
|
|
63
|
-
| `
|
|
64
|
-
| `
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
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`.
|
|
74
74
|
|
|
75
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:
|
|
76
76
|
|
|
@@ -225,7 +225,7 @@ Every error is a `NoheadError`. API errors are `APIError`s with `status`, `type`
|
|
|
225
225
|
|
|
226
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.
|
|
227
227
|
|
|
228
|
-
The client follows no redirects: a `3xx` is an `APIError` (`type` `redirect`) that names where it pointed
|
|
228
|
+
The client follows no redirects: a `3xx` is an `APIError` (`type` `redirect`) that names where it pointed.
|
|
229
229
|
|
|
230
230
|
Other errors: `ConnectionError`, `TimeoutError` (a `ConnectionError`), `AbortError` (your `signal` fired), `UploadError` and `WebhookVerificationError`.
|
|
231
231
|
|
|
@@ -327,8 +327,8 @@ imageUrl(asset.image_url!, { width: 400, dpr: 2, options: ["bl:2"] })
|
|
|
327
327
|
|
|
328
328
|
Frameworks' image components, with `srcset` and all, take a loader from it:
|
|
329
329
|
|
|
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} … />`.
|
|
331
|
-
- **Astro:**
|
|
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`. Defaults for every image go in the service's `config`, and an image's own options win: `service: { entrypoint: "@nohead/sdk/astro/image-service", config: { quality: 70 } }` (`format` too). Needs Astro 5.9 or later.
|
|
332
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"`).
|
|
333
333
|
|
|
334
334
|
The loaders leave other images, the site's own, as they are.
|
|
@@ -378,6 +378,89 @@ results.meta.total_estimate
|
|
|
378
378
|
|
|
379
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.
|
|
380
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()`. On Astro 6 or later, the loader also gives each collection its schema and TypeScript type, from the collection's fields in Nohead (on Astro 5, give each collection a `schema`, under [Schema and types](#schema-and-types)):
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
// src/content.config.ts
|
|
387
|
+
import { defineCollection } from "astro:content"
|
|
388
|
+
import { noheadLoader } from "@nohead/sdk/astro"
|
|
389
|
+
|
|
390
|
+
// Astro reads .env into import.meta.env, not process.env.
|
|
391
|
+
const apiKey = import.meta.env.NOHEAD_API_KEY
|
|
392
|
+
|
|
393
|
+
export const collections = {
|
|
394
|
+
people: defineCollection({
|
|
395
|
+
loader: noheadLoader({ collection: "people", apiKey }),
|
|
396
|
+
}),
|
|
397
|
+
posts: defineCollection({
|
|
398
|
+
loader: noheadLoader({
|
|
399
|
+
collection: "posts",
|
|
400
|
+
expand: ["cover"],
|
|
401
|
+
body: "body",
|
|
402
|
+
apiKey,
|
|
403
|
+
}),
|
|
404
|
+
}),
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
```astro
|
|
409
|
+
---
|
|
410
|
+
// src/pages/posts/[slug].astro
|
|
411
|
+
import { getCollection, getEntry, render } from "astro:content"
|
|
412
|
+
import { Image } from "astro:assets"
|
|
413
|
+
|
|
414
|
+
export async function getStaticPaths() {
|
|
415
|
+
const posts = await getCollection("posts")
|
|
416
|
+
return posts.map((post) => ({ params: { slug: post.data.slug }, props: { post } }))
|
|
417
|
+
}
|
|
418
|
+
const { post } = Astro.props // post.data.title: string, post.data.date: Date
|
|
419
|
+
const { Content } = await render(post)
|
|
420
|
+
const authors = await Promise.all(post.data.authors.map((author) => getEntry(author)))
|
|
421
|
+
const { cover } = post.data // an image field's assets have their URL and size
|
|
422
|
+
---
|
|
423
|
+
<h1>{post.data.title}</h1>
|
|
424
|
+
<p>By {authors.map((author) => author?.data.name).join(" and ")}</p>
|
|
425
|
+
{cover && <Image src={cover.image_url!} width={cover.width!} height={cover.height!} alt={cover.alt ?? ""} />}
|
|
426
|
+
<Content />
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Schema and types
|
|
430
|
+
|
|
431
|
+
Before, each collection repeated its fields as a Zod schema:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
posts: defineCollection({
|
|
435
|
+
loader: noheadLoader({ collection: "posts", expand: ["cover"], body: "body", apiKey }),
|
|
436
|
+
schema: z.object({
|
|
437
|
+
title: z.string(),
|
|
438
|
+
date: z.coerce.date(),
|
|
439
|
+
authors: z.array(reference("people")),
|
|
440
|
+
cover: z.object({ image_url: z.string(), width: z.number(), height: z.number() }).optional(),
|
|
441
|
+
// …and every other field
|
|
442
|
+
}),
|
|
443
|
+
}),
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Now the loader reads the collection's fields when Astro syncs (`astro dev`, `astro build`, `astro sync`) and writes both the schema and the type, so `getCollection("posts")` is typed with none of that:
|
|
447
|
+
|
|
448
|
+
- Text fields are strings; integers and decimals, numbers; dates, `Date`s; enums, their options; booleans, `boolean` (`false` when unset); JSON, `unknown`. A multiple field is an array.
|
|
449
|
+
- A required field is required, others optional. An expanded asset or relation is always optional, since one deleted or unpublished since is left out.
|
|
450
|
+
- A relation is a `reference()` to the Astro collection named like its target's slug, so name your collections after their slugs, and define one for every target that relations point to (`categories` for a post's `category`), or `getEntry` has nothing to find. Expanded, it is an `EmbeddedRecord`, and an expanded asset an `EmbeddedAsset`. An asset that isn't expanded stays its ID.
|
|
451
|
+
- The `body` field isn't in the data: `render(entry)` has it.
|
|
452
|
+
- The schema takes the key's `schema:read` scope, which published-only keys can have; without it the sync fails and says so.
|
|
453
|
+
- A `schema` given to `defineCollection` takes its place, as in any Astro loader. Use one to transform values, or on Astro 5, which doesn't ask loaders for a schema.
|
|
454
|
+
|
|
455
|
+
- **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.
|
|
456
|
+
- **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.
|
|
457
|
+
- **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} />`.
|
|
458
|
+
- **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())`.
|
|
459
|
+
- **Relations** stay record IDs, for `reference("people")` and `getEntry`.
|
|
460
|
+
- **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.
|
|
461
|
+
- **Empty fields** are left out of `data` rather than `null`, so `.optional()` fits them. Without the loader's schema, dates and times are strings: `z.coerce.date()` makes them `Date`s.
|
|
462
|
+
- **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.
|
|
463
|
+
|
|
381
464
|
## Schema
|
|
382
465
|
|
|
383
466
|
```ts
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
* Defaults for every image go in the service's `config`, and an image's own
|
|
16
|
+
* `quality` or `format` wins:
|
|
17
|
+
*
|
|
18
|
+
* service: { entrypoint: "@nohead/sdk/astro/image-service", config: { quality: 70 } }
|
|
19
|
+
*/
|
|
20
|
+
import type { ExternalImageService } from "astro";
|
|
21
|
+
/** The service's `config` in astro.config: defaults for every image. */
|
|
22
|
+
export interface ImageServiceConfig {
|
|
23
|
+
/** 1 to 100, or one of Astro's presets (`"high"` is 80). */
|
|
24
|
+
quality?: number | "low" | "mid" | "high" | "max";
|
|
25
|
+
/** `"webp"` unless set. */
|
|
26
|
+
format?: "webp" | "avif" | "jpeg" | "jpg" | "png";
|
|
27
|
+
}
|
|
28
|
+
declare const imageService: ExternalImageService<ImageServiceConfig>;
|
|
29
|
+
export default imageService;
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
// A Picture's <source type> needs `jpeg`, not `jpg`.
|
|
12
|
+
validated.format ??=
|
|
13
|
+
formatOf(imageConfig?.service?.config?.format) ?? "webp";
|
|
14
|
+
}
|
|
15
|
+
return validated;
|
|
16
|
+
},
|
|
17
|
+
getURL(options, imageConfig) {
|
|
18
|
+
const defaults = imageConfig?.service?.config;
|
|
19
|
+
const { src } = options;
|
|
20
|
+
// An imported image's metadata, or another URL.
|
|
21
|
+
if (!isImageUrl(src))
|
|
22
|
+
return typeof src === "string" ? src : src.src;
|
|
23
|
+
return imageUrl(src, {
|
|
24
|
+
width: options.width,
|
|
25
|
+
height: options.height,
|
|
26
|
+
fit: fitOf(options.fit, options.width, options.height),
|
|
27
|
+
format: formatOf(options.format ?? defaults?.format ?? "webp"),
|
|
28
|
+
quality: qualityOf(options.quality ?? defaults?.quality),
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
export default imageService;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type * as Zod from "astro/zod";
|
|
2
|
+
import type * as G from "./generated/types.gen.ts";
|
|
3
|
+
import type { RichText } from "./types.ts";
|
|
4
|
+
type Z = typeof Zod.z;
|
|
5
|
+
type Schema = Zod.ZodType;
|
|
6
|
+
export interface SchemaOptions {
|
|
7
|
+
/** Fields whose IDs the loader replaces with what they point at. */
|
|
8
|
+
expand: string[];
|
|
9
|
+
/** The rich text field that moves to `rendered`, out of the data. */
|
|
10
|
+
body: string | undefined;
|
|
11
|
+
richText: RichText;
|
|
12
|
+
/** Collection slugs by ID, for relations' targets. */
|
|
13
|
+
slugs: Map<string, string>;
|
|
14
|
+
}
|
|
15
|
+
/** The schema and the `Entry` type (a module, as Astro writes it) of the data. */
|
|
16
|
+
export declare function collectionSchema(z: Z, collection: G.Collection, options: SchemaOptions): {
|
|
17
|
+
schema: Schema;
|
|
18
|
+
types: string;
|
|
19
|
+
};
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** The schema and the `Entry` type (a module, as Astro writes it) of the data. */
|
|
2
|
+
export function collectionSchema(z, collection, options) {
|
|
3
|
+
const shape = {};
|
|
4
|
+
const lines = [];
|
|
5
|
+
for (const field of collection.fields) {
|
|
6
|
+
if (field.key === options.body)
|
|
7
|
+
continue;
|
|
8
|
+
const { schema, type, optional } = fieldSchema(z, field, options);
|
|
9
|
+
shape[field.key] = optional ? schema.optional() : schema;
|
|
10
|
+
const description = field.configuration.description;
|
|
11
|
+
const doc = `${field.name}${description ? `: ${description}` : ""}`;
|
|
12
|
+
lines.push(` /** ${doc.replaceAll("*/", "*\\/")} */`, ` ${JSON.stringify(field.key)}${optional ? "?" : ""}: ${type}`);
|
|
13
|
+
}
|
|
14
|
+
const body = lines.join("\n");
|
|
15
|
+
const used = ["EmbeddedAsset", "EmbeddedRecord"].filter((name) => body.includes(name));
|
|
16
|
+
const types = [
|
|
17
|
+
`// The data of the ${collection.slug} collection's entries, from its fields in Nohead (@nohead/sdk/astro).`,
|
|
18
|
+
...(used.length
|
|
19
|
+
? [`import type { ${used.join(", ")} } from "@nohead/sdk/astro"`]
|
|
20
|
+
: []),
|
|
21
|
+
``,
|
|
22
|
+
`export type Entry = {`,
|
|
23
|
+
...lines,
|
|
24
|
+
`}`,
|
|
25
|
+
``,
|
|
26
|
+
].join("\n");
|
|
27
|
+
return { schema: z.object(shape), types };
|
|
28
|
+
}
|
|
29
|
+
function fieldSchema(z, field, { expand, richText, slugs }) {
|
|
30
|
+
const expanded = expand.includes(field.key);
|
|
31
|
+
let one;
|
|
32
|
+
switch (field.type) {
|
|
33
|
+
case "text":
|
|
34
|
+
case "long_text":
|
|
35
|
+
one = { schema: z.string(), type: "string" };
|
|
36
|
+
break;
|
|
37
|
+
case "rich_text":
|
|
38
|
+
one =
|
|
39
|
+
richText === "document"
|
|
40
|
+
? {
|
|
41
|
+
schema: z.record(z.string(), z.unknown()),
|
|
42
|
+
type: "Record<string, unknown>",
|
|
43
|
+
}
|
|
44
|
+
: { schema: z.string(), type: "string" };
|
|
45
|
+
break;
|
|
46
|
+
case "integer":
|
|
47
|
+
one = { schema: z.number().int(), type: "number" };
|
|
48
|
+
break;
|
|
49
|
+
case "decimal":
|
|
50
|
+
one = { schema: z.number(), type: "number" };
|
|
51
|
+
break;
|
|
52
|
+
case "date":
|
|
53
|
+
one = { schema: z.coerce.date(), type: "Date" };
|
|
54
|
+
break;
|
|
55
|
+
case "boolean":
|
|
56
|
+
// No value reads as false: it is never required.
|
|
57
|
+
return {
|
|
58
|
+
schema: z.boolean().default(false),
|
|
59
|
+
type: "boolean",
|
|
60
|
+
optional: false,
|
|
61
|
+
};
|
|
62
|
+
case "enum": {
|
|
63
|
+
const options = field.configuration.options;
|
|
64
|
+
one = {
|
|
65
|
+
schema: z.enum(options),
|
|
66
|
+
type: options.map((option) => JSON.stringify(option)).join(" | "),
|
|
67
|
+
};
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case "asset":
|
|
71
|
+
one = expanded
|
|
72
|
+
? { schema: embeddedAsset(z), type: "EmbeddedAsset" }
|
|
73
|
+
: { schema: z.string(), type: "string" };
|
|
74
|
+
break;
|
|
75
|
+
case "relation": {
|
|
76
|
+
const target = slugs.get(field.configuration.target_collection_id);
|
|
77
|
+
one = expanded
|
|
78
|
+
? { schema: embeddedRecord(z), type: "EmbeddedRecord" }
|
|
79
|
+
: target
|
|
80
|
+
? {
|
|
81
|
+
schema: reference(z, target),
|
|
82
|
+
type: `{ collection: ${JSON.stringify(target)}; id: string }`,
|
|
83
|
+
}
|
|
84
|
+
: { schema: z.string(), type: "string" };
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
one = { schema: z.unknown(), type: "unknown" };
|
|
89
|
+
}
|
|
90
|
+
if (field.multiple) {
|
|
91
|
+
const type = /[|{]/.test(one.type) ? `(${one.type})[]` : `${one.type}[]`;
|
|
92
|
+
return { schema: z.array(one.schema), type, optional: !field.required };
|
|
93
|
+
}
|
|
94
|
+
// An expanded asset or record deleted since (or unpublished) is left
|
|
95
|
+
// out, so it may be missing even when the field is required.
|
|
96
|
+
const optional = !field.required ||
|
|
97
|
+
(expanded && (field.type === "asset" || field.type === "relation"));
|
|
98
|
+
return { ...one, optional };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* What Astro's `reference(collection)` makes of a record ID: a lookup for
|
|
102
|
+
* `getEntry`. Built here, since `astro:content` is Astro's virtual module.
|
|
103
|
+
*/
|
|
104
|
+
function reference(z, collection) {
|
|
105
|
+
return z.union([
|
|
106
|
+
z.string().transform((id) => ({ id, collection })),
|
|
107
|
+
z.object({ id: z.string(), collection: z.literal(collection) }),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
function embeddedAsset(z) {
|
|
111
|
+
return z.object({
|
|
112
|
+
id: z.string(),
|
|
113
|
+
filename: z.string(),
|
|
114
|
+
content_type: z.string(),
|
|
115
|
+
alt: z.string().optional(),
|
|
116
|
+
width: z.number().optional(),
|
|
117
|
+
height: z.number().optional(),
|
|
118
|
+
image_url: z.string().optional(),
|
|
119
|
+
file_url: z.string().optional(),
|
|
120
|
+
media_url: z.string().optional(),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function embeddedRecord(z) {
|
|
124
|
+
return z.object({ id: z.string(), data: z.record(z.string(), z.unknown()) });
|
|
125
|
+
}
|
package/dist/astro.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
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). On Astro 6 and later, the
|
|
16
|
+
* collection's fields give the entries their schema and TypeScript type.
|
|
17
|
+
*/
|
|
18
|
+
import type { Loader } from "astro/loaders";
|
|
19
|
+
import { type ClientOptions } from "./core.ts";
|
|
20
|
+
import type * as G from "./generated/types.gen.ts";
|
|
21
|
+
import type { RecordFilter } from "./types.ts";
|
|
22
|
+
export interface NoheadLoaderOptions extends ClientOptions {
|
|
23
|
+
/** The collection's slug or ID. */
|
|
24
|
+
collection: string;
|
|
25
|
+
/**
|
|
26
|
+
* Filters, added to the default `{ published_at: { exists: true } }`, so
|
|
27
|
+
* only published records load whatever the key may read. Set
|
|
28
|
+
* `published_at` to change that: `undefined` loads every record.
|
|
29
|
+
*/
|
|
30
|
+
filter?: RecordFilter;
|
|
31
|
+
/**
|
|
32
|
+
* Asset (or relation) fields whose IDs to replace with what they point
|
|
33
|
+
* at: an asset as an `EmbeddedAsset`, a published record as an
|
|
34
|
+
* `EmbeddedRecord`. At most 5. Relations otherwise stay record IDs, for
|
|
35
|
+
* `reference()`.
|
|
36
|
+
*/
|
|
37
|
+
expand?: string[];
|
|
38
|
+
/**
|
|
39
|
+
* A rich text field to render: it moves from `data` into the entry's
|
|
40
|
+
* `rendered`, so `render(entry)` returns its `<Content />`. As HTML (the
|
|
41
|
+
* default), or with `richText: "markdown"`, through Astro's Markdown
|
|
42
|
+
* pipeline (your remark and rehype plugins, syntax highlighting,
|
|
43
|
+
* headings).
|
|
44
|
+
*/
|
|
45
|
+
body?: string;
|
|
46
|
+
/**
|
|
47
|
+
* How rich text fields read: `"html"` by default, strings ready for
|
|
48
|
+
* `set:html`, or `"markdown"`. The `"document"` form is the API's.
|
|
49
|
+
*/
|
|
50
|
+
richText?: G.RichText;
|
|
51
|
+
}
|
|
52
|
+
/** An expanded asset field's value: what a page needs to show the file. */
|
|
53
|
+
export interface EmbeddedAsset {
|
|
54
|
+
id: string;
|
|
55
|
+
filename: string;
|
|
56
|
+
content_type: string;
|
|
57
|
+
/** The asset's alt text, when it has some. */
|
|
58
|
+
alt?: string;
|
|
59
|
+
/** For images: their size, and their URL (see `@nohead/sdk/images`). */
|
|
60
|
+
width?: number;
|
|
61
|
+
height?: number;
|
|
62
|
+
image_url?: string;
|
|
63
|
+
/** The file as uploaded, for any ready asset. */
|
|
64
|
+
file_url?: string;
|
|
65
|
+
/** For video and audio: the URL that plays inline. */
|
|
66
|
+
media_url?: string;
|
|
67
|
+
}
|
|
68
|
+
/** An expanded relation field's value: the related record's ID and fields. */
|
|
69
|
+
export interface EmbeddedRecord {
|
|
70
|
+
id: string;
|
|
71
|
+
data: Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* An Astro content loader for a Nohead collection. It reads the key from
|
|
75
|
+
* `apiKey` or `NOHEAD_API_KEY`, and takes the other client options too
|
|
76
|
+
* (`timeout`, `maxRetries`, `headers`…). Empty fields are left out of an
|
|
77
|
+
* entry's data, so a schema's `.optional()` fits them.
|
|
78
|
+
*/
|
|
79
|
+
export declare function noheadLoader(options: NoheadLoaderOptions): Loader;
|
package/dist/astro.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Nohead } from "./client.js";
|
|
2
|
+
import { environment } from "./core.js";
|
|
3
|
+
import { collectionSchema } from "./astro-schema.js";
|
|
4
|
+
import { AuthorizationError, NoheadError } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* An Astro content loader for a Nohead collection. It reads the key from
|
|
7
|
+
* `apiKey` or `NOHEAD_API_KEY`, and takes the other client options too
|
|
8
|
+
* (`timeout`, `maxRetries`, `headers`…). Empty fields are left out of an
|
|
9
|
+
* entry's data, so a schema's `.optional()` fits them.
|
|
10
|
+
*/
|
|
11
|
+
export function noheadLoader(options) {
|
|
12
|
+
const { collection, filter, expand, body, richText = "html", ...client } = options;
|
|
13
|
+
if (!collection)
|
|
14
|
+
throw new TypeError("noheadLoader needs a collection");
|
|
15
|
+
if (body && richText === "document") {
|
|
16
|
+
throw new TypeError('noheadLoader renders its body from HTML or Markdown: set richText to "html" or "markdown"');
|
|
17
|
+
}
|
|
18
|
+
const connect = () => {
|
|
19
|
+
// Astro reads .env into import.meta.env, not process.env.
|
|
20
|
+
if (!client.apiKey && !environment().NOHEAD_API_KEY) {
|
|
21
|
+
throw new NoheadError("Missing API key: pass `apiKey: import.meta.env.NOHEAD_API_KEY` to noheadLoader, or set NOHEAD_API_KEY in the environment");
|
|
22
|
+
}
|
|
23
|
+
return new Nohead({ ...client, richText });
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
name: "nohead",
|
|
27
|
+
// Astro 6 and later: the entries' schema and type, from the collection's
|
|
28
|
+
// fields. A schema given to defineCollection takes its place.
|
|
29
|
+
async createSchema() {
|
|
30
|
+
const nohead = connect();
|
|
31
|
+
const { z } = await import("astro/zod");
|
|
32
|
+
try {
|
|
33
|
+
// Relations name their target by ID; references need its slug.
|
|
34
|
+
const slugsById = async () => {
|
|
35
|
+
const slugs = new Map();
|
|
36
|
+
for await (const each of nohead.collections.list()) {
|
|
37
|
+
slugs.set(each.id, each.slug);
|
|
38
|
+
}
|
|
39
|
+
return slugs;
|
|
40
|
+
};
|
|
41
|
+
const [fields, slugs] = await Promise.all([
|
|
42
|
+
nohead.collections.get(collection),
|
|
43
|
+
slugsById(),
|
|
44
|
+
]);
|
|
45
|
+
return collectionSchema(z, fields, {
|
|
46
|
+
expand: expand ?? [],
|
|
47
|
+
body,
|
|
48
|
+
richText,
|
|
49
|
+
slugs,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (!(error instanceof AuthorizationError))
|
|
54
|
+
throw error;
|
|
55
|
+
throw new NoheadError(`noheadLoader reads the ${collection} collection's fields for its schema, which takes the key's schema:read scope: add it, or pass a schema to defineCollection`, { cause: error });
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async load(context) {
|
|
59
|
+
const { store, logger } = context;
|
|
60
|
+
const nohead = connect();
|
|
61
|
+
const records = [];
|
|
62
|
+
for await (const record of nohead.records.list(collection, {
|
|
63
|
+
limit: 100,
|
|
64
|
+
filter: { published_at: { exists: true }, ...filter },
|
|
65
|
+
expand,
|
|
66
|
+
})) {
|
|
67
|
+
records.push(record);
|
|
68
|
+
}
|
|
69
|
+
// Embedded records are published too, unless entries may not be.
|
|
70
|
+
const publishedAt = filter && "published_at" in filter
|
|
71
|
+
? filter.published_at
|
|
72
|
+
: { exists: true };
|
|
73
|
+
const publishedOnly = !(publishedAt === undefined || publishedAt.exists === false);
|
|
74
|
+
// Every entry is made before the store changes, so a failure leaves
|
|
75
|
+
// the entries as they were.
|
|
76
|
+
const entries = await Promise.all(records.map(async (record) => {
|
|
77
|
+
const data = entryData(record, publishedOnly);
|
|
78
|
+
const digest = context.generateDigest(data);
|
|
79
|
+
if (store.get(record.id)?.digest === digest)
|
|
80
|
+
return undefined;
|
|
81
|
+
let rendered;
|
|
82
|
+
if (body !== undefined) {
|
|
83
|
+
const value = data[body];
|
|
84
|
+
delete data[body];
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
rendered =
|
|
87
|
+
richText === "markdown"
|
|
88
|
+
? await context.renderMarkdown(value)
|
|
89
|
+
: { html: value };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const parsed = await context.parseData({ id: record.id, data });
|
|
93
|
+
return { id: record.id, data: parsed, digest, rendered };
|
|
94
|
+
}));
|
|
95
|
+
const ids = new Set(records.map((record) => record.id));
|
|
96
|
+
for (const id of store.keys())
|
|
97
|
+
if (!ids.has(id))
|
|
98
|
+
store.delete(id);
|
|
99
|
+
for (const entry of entries)
|
|
100
|
+
if (entry)
|
|
101
|
+
store.set(entry);
|
|
102
|
+
logger.info(`Loaded ${records.length} records from ${collection}`);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A record's fields as an entry's data: without empty values, and with each
|
|
108
|
+
* expanded asset or relation in place of its ID.
|
|
109
|
+
*/
|
|
110
|
+
function entryData(record, publishedOnly) {
|
|
111
|
+
const data = present(record.data);
|
|
112
|
+
// Gone since, or (as entries are) unpublished: left out.
|
|
113
|
+
const shown = (embedded) => embedded !== null &&
|
|
114
|
+
!(publishedOnly && embedded.object === "record" && !embedded.published_at);
|
|
115
|
+
for (const [key, embedded] of Object.entries(record.expanded ?? {})) {
|
|
116
|
+
const value = record.data[key];
|
|
117
|
+
// A rich text field's expansion (the assets its images show) is a list
|
|
118
|
+
// beside a single value: the field keeps its text.
|
|
119
|
+
if (Array.isArray(value) && Array.isArray(embedded)) {
|
|
120
|
+
data[key] = embedded.filter(shown).map(entryValue);
|
|
121
|
+
}
|
|
122
|
+
else if (typeof value === "string" && !Array.isArray(embedded)) {
|
|
123
|
+
if (shown(embedded))
|
|
124
|
+
data[key] = entryValue(embedded);
|
|
125
|
+
else
|
|
126
|
+
delete data[key];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return data;
|
|
130
|
+
}
|
|
131
|
+
function entryValue(embedded) {
|
|
132
|
+
if (embedded.object === "record") {
|
|
133
|
+
return { id: embedded.id, data: present(embedded.data) };
|
|
134
|
+
}
|
|
135
|
+
const { id, filename, content_type } = embedded;
|
|
136
|
+
return {
|
|
137
|
+
id,
|
|
138
|
+
filename,
|
|
139
|
+
content_type,
|
|
140
|
+
...present({
|
|
141
|
+
alt: embedded.alt,
|
|
142
|
+
width: embedded.width,
|
|
143
|
+
height: embedded.height,
|
|
144
|
+
image_url: embedded.image_url,
|
|
145
|
+
file_url: embedded.file_url,
|
|
146
|
+
media_url: embedded.media_url,
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** The values that are set: Zod's `.optional()` takes undefined, not null. */
|
|
151
|
+
function present(values) {
|
|
152
|
+
return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== null && value !== undefined));
|
|
153
|
+
}
|
package/dist/core.d.ts
CHANGED
package/dist/core.js
CHANGED
|
@@ -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);
|
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(
|
|
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
|
-
|
|
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
|
|
110
|
-
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:
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.14.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.14.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nohead/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|