@nohead/sdk 0.13.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 CHANGED
@@ -5,6 +5,19 @@ 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
+
8
21
  ## 0.13.0
9
22
 
10
23
  - `@nohead/sdk/astro`: `noheadLoader`, a loader for Astro's content
package/README.md CHANGED
@@ -328,7 +328,7 @@ imageUrl(asset.image_url!, { width: 400, dpr: 2, options: ["bl:2"] })
328
328
  Frameworks' image components, with `srcset` and all, take a loader from it:
329
329
 
330
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.
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.
@@ -380,27 +380,19 @@ Search needs the `search:read` scope and covers every collection the key can rea
380
380
 
381
381
  ## Astro
382
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()`:
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
384
 
385
385
  ```ts
386
386
  // src/content.config.ts
387
- import { defineCollection, reference } from "astro:content"
388
- import { z } from "astro/zod"
387
+ import { defineCollection } from "astro:content"
389
388
  import { noheadLoader } from "@nohead/sdk/astro"
390
389
 
391
390
  // Astro reads .env into import.meta.env, not process.env.
392
391
  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
392
 
400
393
  export const collections = {
401
394
  people: defineCollection({
402
395
  loader: noheadLoader({ collection: "people", apiKey }),
403
- schema: z.object({ name: z.string(), slug: z.string() }),
404
396
  }),
405
397
  posts: defineCollection({
406
398
  loader: noheadLoader({
@@ -409,13 +401,6 @@ export const collections = {
409
401
  body: "body",
410
402
  apiKey,
411
403
  }),
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
404
  }),
420
405
  }
421
406
  ```
@@ -423,28 +408,57 @@ export const collections = {
423
408
  ```astro
424
409
  ---
425
410
  // src/pages/posts/[slug].astro
426
- import { getCollection, render } from "astro:content"
411
+ import { getCollection, getEntry, render } from "astro:content"
427
412
  import { Image } from "astro:assets"
428
413
 
429
414
  export async function getStaticPaths() {
430
415
  const posts = await getCollection("posts")
431
416
  return posts.map((post) => ({ params: { slug: post.data.slug }, props: { post } }))
432
417
  }
433
- const { post } = Astro.props
418
+ const { post } = Astro.props // post.data.title: string, post.data.date: Date
434
419
  const { Content } = await render(post)
435
- const { cover } = post.data
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
436
422
  ---
437
- {cover && <Image src={cover.image_url} width={cover.width} height={cover.height} alt={cover.alt ?? ""} />}
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 ?? ""} />}
438
426
  <Content />
439
427
  ```
440
428
 
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.
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.
442
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.
443
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} />`.
444
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())`.
445
459
  - **Relations** stay record IDs, for `reference("people")` and `getEntry`.
446
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.
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.
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.
448
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.
449
463
 
450
464
  ## Schema
@@ -11,7 +11,19 @@
11
11
  * attributes, `srcset` for `widths` and `densities`). WebP unless a
12
12
  * `format` says otherwise. Other images (the site's own, imported or
13
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 } }
14
19
  */
15
20
  import type { ExternalImageService } from "astro";
16
- declare const imageService: ExternalImageService;
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>;
17
29
  export default imageService;
@@ -7,11 +7,15 @@ const imageService = {
7
7
  async validateOptions(options, imageConfig, logger) {
8
8
  const validated = await baseService.validateOptions(options, imageConfig, logger);
9
9
  // So the srcset's <source type> says what the URLs serve.
10
- if (isImageUrl(validated.src))
11
- validated.format ??= "webp";
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
+ }
12
15
  return validated;
13
16
  },
14
- getURL(options) {
17
+ getURL(options, imageConfig) {
18
+ const defaults = imageConfig?.service?.config;
15
19
  const { src } = options;
16
20
  // An imported image's metadata, or another URL.
17
21
  if (!isImageUrl(src))
@@ -20,8 +24,8 @@ const imageService = {
20
24
  width: options.width,
21
25
  height: options.height,
22
26
  fit: fitOf(options.fit, options.width, options.height),
23
- format: formatOf(options.format ?? "webp"),
24
- quality: qualityOf(options.quality),
27
+ format: formatOf(options.format ?? defaults?.format ?? "webp"),
28
+ quality: qualityOf(options.quality ?? defaults?.quality),
25
29
  });
26
30
  },
27
31
  };
@@ -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 CHANGED
@@ -12,7 +12,8 @@
12
12
  * }
13
13
  *
14
14
  * Every published record becomes an entry, by its record ID, when the site
15
- * builds (and when `astro dev` starts or syncs).
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.
16
17
  */
17
18
  import type { Loader } from "astro/loaders";
18
19
  import { type ClientOptions } from "./core.ts";
package/dist/astro.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Nohead } from "./client.js";
2
2
  import { environment } from "./core.js";
3
- import { NoheadError } from "./errors.js";
3
+ import { collectionSchema } from "./astro-schema.js";
4
+ import { AuthorizationError, NoheadError } from "./errors.js";
4
5
  /**
5
6
  * An Astro content loader for a Nohead collection. It reads the key from
6
7
  * `apiKey` or `NOHEAD_API_KEY`, and takes the other client options too
@@ -14,15 +15,49 @@ export function noheadLoader(options) {
14
15
  if (body && richText === "document") {
15
16
  throw new TypeError('noheadLoader renders its body from HTML or Markdown: set richText to "html" or "markdown"');
16
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
+ };
17
25
  return {
18
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
+ },
19
58
  async load(context) {
20
59
  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 });
60
+ const nohead = connect();
26
61
  const records = [];
27
62
  for await (const record of nohead.records.list(collection, {
28
63
  limit: 100,
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.13.0";
1
+ export declare const VERSION = "0.14.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.13.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.13.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",