@headroom-cms/api 0.1.1

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.
@@ -0,0 +1,49 @@
1
+ interface FieldDef {
2
+ name: string;
3
+ type: string;
4
+ label: string;
5
+ options?: Record<string, unknown> | null;
6
+ }
7
+ interface CollectionSummary {
8
+ name: string;
9
+ label: string;
10
+ labelSingular: string;
11
+ slug?: string;
12
+ singleton: boolean;
13
+ }
14
+ /** Defines a belongs-to relationship on a collection. */
15
+ interface RelationshipDef {
16
+ name: string;
17
+ label: string;
18
+ targetCollection: string;
19
+ multiple: boolean;
20
+ }
21
+ interface Collection extends CollectionSummary {
22
+ version: number;
23
+ fields: FieldDef[];
24
+ relationships?: RelationshipDef[];
25
+ }
26
+ interface CollectionListResult {
27
+ items: CollectionSummary[];
28
+ }
29
+
30
+ /** Minimal client interface for schema generation (avoids private-property TS issues). */
31
+ interface SchemaGenClient {
32
+ listCollections(): Promise<CollectionListResult>;
33
+ getCollection(name: string): Promise<Collection>;
34
+ }
35
+ interface GenerateOptions {
36
+ /** Which collections to generate (default: all) */
37
+ collections?: string[];
38
+ /** Import source for z — "astro/zod" (default) or "zod" */
39
+ zodImport?: string;
40
+ }
41
+ /** Map a single FieldDef to a Zod expression string. */
42
+ declare function fieldToZod(field: FieldDef): string;
43
+ /**
44
+ * Generate a complete TypeScript file with Zod schemas for all (or selected)
45
+ * collections. Fetches collection definitions from the Headroom public API.
46
+ */
47
+ declare function generateZodSchemas(client: SchemaGenClient, opts?: GenerateOptions): Promise<string>;
48
+
49
+ export { type GenerateOptions, fieldToZod, generateZodSchemas };
@@ -0,0 +1,49 @@
1
+ interface FieldDef {
2
+ name: string;
3
+ type: string;
4
+ label: string;
5
+ options?: Record<string, unknown> | null;
6
+ }
7
+ interface CollectionSummary {
8
+ name: string;
9
+ label: string;
10
+ labelSingular: string;
11
+ slug?: string;
12
+ singleton: boolean;
13
+ }
14
+ /** Defines a belongs-to relationship on a collection. */
15
+ interface RelationshipDef {
16
+ name: string;
17
+ label: string;
18
+ targetCollection: string;
19
+ multiple: boolean;
20
+ }
21
+ interface Collection extends CollectionSummary {
22
+ version: number;
23
+ fields: FieldDef[];
24
+ relationships?: RelationshipDef[];
25
+ }
26
+ interface CollectionListResult {
27
+ items: CollectionSummary[];
28
+ }
29
+
30
+ /** Minimal client interface for schema generation (avoids private-property TS issues). */
31
+ interface SchemaGenClient {
32
+ listCollections(): Promise<CollectionListResult>;
33
+ getCollection(name: string): Promise<Collection>;
34
+ }
35
+ interface GenerateOptions {
36
+ /** Which collections to generate (default: all) */
37
+ collections?: string[];
38
+ /** Import source for z — "astro/zod" (default) or "zod" */
39
+ zodImport?: string;
40
+ }
41
+ /** Map a single FieldDef to a Zod expression string. */
42
+ declare function fieldToZod(field: FieldDef): string;
43
+ /**
44
+ * Generate a complete TypeScript file with Zod schemas for all (or selected)
45
+ * collections. Fetches collection definitions from the Headroom public API.
46
+ */
47
+ declare function generateZodSchemas(client: SchemaGenClient, opts?: GenerateOptions): Promise<string>;
48
+
49
+ export { type GenerateOptions, fieldToZod, generateZodSchemas };
@@ -0,0 +1,144 @@
1
+ // src/codegen/zod.ts
2
+ function fieldToZod(field) {
3
+ switch (field.type) {
4
+ case "text":
5
+ case "textarea":
6
+ case "rich-text":
7
+ case "slug":
8
+ case "url":
9
+ case "email":
10
+ case "select":
11
+ return "z.string().optional()";
12
+ case "number":
13
+ return "z.number().optional()";
14
+ case "boolean":
15
+ return "z.boolean().optional()";
16
+ case "date":
17
+ case "datetime":
18
+ return "z.number().optional()";
19
+ case "media":
20
+ return "z.string().optional()";
21
+ case "blocks":
22
+ return "z.array(blockSchema).optional()";
23
+ case "multiselect":
24
+ return "z.array(z.string()).optional()";
25
+ case "content": {
26
+ const multiple = field.options?.multiple === true;
27
+ return multiple ? "z.array(z.string()).optional()" : "z.string().optional()";
28
+ }
29
+ case "array": {
30
+ const itemFields = field.options?.itemFields;
31
+ if (itemFields && itemFields.length > 0) {
32
+ const props = itemFields.map((f) => `${f.name}: ${fieldToZod(f)}`).join(", ");
33
+ return `z.array(z.object({ ${props} })).optional()`;
34
+ }
35
+ return "z.array(z.record(z.unknown())).optional()";
36
+ }
37
+ default:
38
+ return "z.unknown().optional()";
39
+ }
40
+ }
41
+ var PREAMBLE = `// Auto-generated by @headroom-cms/api \u2014 do not edit
42
+
43
+ `;
44
+ var SHARED_TYPES = `
45
+ /** Block content type (loosely typed \u2014 blocks are rendered dynamically). */
46
+ type Block = {
47
+ id: string;
48
+ type: string;
49
+ props?: Record<string, unknown> | null;
50
+ content?: unknown;
51
+ children?: Block[];
52
+ };
53
+
54
+ /** Shared metadata fields present on all content items. */
55
+ const contentMeta = {
56
+ contentId: z.string(),
57
+ collection: z.string(),
58
+ slug: z.string(),
59
+ title: z.string(),
60
+ snippet: z.string(),
61
+ publishedAt: z.number(),
62
+ tags: z.array(z.string()),
63
+ coverUrl: z.string().optional(),
64
+ coverMediaId: z.string().optional(),
65
+ lastPublishedAt: z.number().optional(),
66
+ };
67
+
68
+ /** Block content schema (loosely typed \u2014 blocks are rendered dynamically). */
69
+ const blockSchema: ZodType<Block> = z.object({
70
+ id: z.string(),
71
+ type: z.string(),
72
+ props: z.record(z.unknown()).nullable().optional(),
73
+ content: z.unknown().optional(),
74
+ children: z.lazy(() => z.array(blockSchema)).optional(),
75
+ });
76
+
77
+ /** Content reference schema. */
78
+ const publicContentRefSchema = z.object({
79
+ contentId: z.string(),
80
+ collection: z.string(),
81
+ slug: z.string(),
82
+ title: z.string(),
83
+ published: z.boolean(),
84
+ });
85
+
86
+ const refsMapSchema = z.record(publicContentRefSchema);
87
+ `;
88
+ function collectionToZod(col) {
89
+ const bodyFields = col.fields.filter((f) => f.type !== "blocks");
90
+ const hasBlocks = col.fields.some((f) => f.type === "blocks");
91
+ const hasBodyFields = bodyFields.length > 0 || hasBlocks;
92
+ const lines = [];
93
+ const exportName = camelCase(col.name) + "Schema";
94
+ lines.push(`/** ${col.label}${col.singleton ? " (singleton)" : ""} */`);
95
+ lines.push(`export const ${exportName} = z`);
96
+ if (!hasBodyFields) {
97
+ lines.push(" .object({");
98
+ lines.push(" ...contentMeta,");
99
+ lines.push(" })");
100
+ } else {
101
+ lines.push(" .object({");
102
+ lines.push(" ...contentMeta,");
103
+ lines.push(" body: z");
104
+ lines.push(" .object({");
105
+ if (hasBlocks) {
106
+ lines.push(" content: z.array(blockSchema).optional(),");
107
+ }
108
+ for (const field of bodyFields) {
109
+ lines.push(` ${field.name}: ${fieldToZod(field)},`);
110
+ }
111
+ lines.push(" })");
112
+ lines.push(" .passthrough()");
113
+ lines.push(" .optional(),");
114
+ lines.push(" _refs: refsMapSchema.optional(),");
115
+ lines.push(" })");
116
+ }
117
+ lines.push(" .passthrough();");
118
+ return lines.join("\n");
119
+ }
120
+ function camelCase(s) {
121
+ return s.replace(/[-_]+(\w)/g, (_, c) => c.toUpperCase());
122
+ }
123
+ async function generateZodSchemas(client, opts) {
124
+ const zodImport = opts?.zodImport ?? "astro/zod";
125
+ const { items: summaries } = await client.listCollections();
126
+ const filtered = opts?.collections ? summaries.filter((s) => opts.collections.includes(s.name)) : summaries;
127
+ const collections = await Promise.all(
128
+ filtered.map((s) => client.getCollection(s.name))
129
+ );
130
+ const parts = [];
131
+ parts.push(PREAMBLE);
132
+ parts.push(`import { z, type ZodType } from "${zodImport}";
133
+ `);
134
+ parts.push(SHARED_TYPES);
135
+ for (const col of collections) {
136
+ parts.push("");
137
+ parts.push(collectionToZod(col));
138
+ }
139
+ return parts.join("\n") + "\n";
140
+ }
141
+ export {
142
+ fieldToZod,
143
+ generateZodSchemas
144
+ };
@@ -0,0 +1,96 @@
1
+ /* @headroom-cms/api — default block styles */
2
+
3
+ :where(.hr-bold) { font-weight: bold; }
4
+ :where(.hr-italic) { font-style: italic; }
5
+ :where(.hr-underline) { text-decoration: underline; }
6
+ :where(.hr-strikethrough) { text-decoration: line-through; }
7
+ :where(.hr-code) {
8
+ font-family: ui-monospace, monospace;
9
+ background: var(--hr-code-bg, #f3f4f6);
10
+ padding: 0.125em 0.25em;
11
+ border-radius: 0.25em;
12
+ font-size: 0.875em;
13
+ }
14
+ :where(.hr-link) {
15
+ color: var(--hr-link-color, #2563eb);
16
+ text-decoration: underline;
17
+ text-underline-offset: 2px;
18
+ }
19
+ :where(.hr-image img) {
20
+ max-width: 100%;
21
+ height: auto;
22
+ border-radius: var(--hr-image-radius, 0.5rem);
23
+ }
24
+ :where(.hr-image figcaption) {
25
+ margin-top: 0.5em;
26
+ font-size: 0.875em;
27
+ color: var(--hr-caption-color, #6b7280);
28
+ text-align: center;
29
+ }
30
+ :where(.hr-code-block) {
31
+ background: var(--hr-code-block-bg, #1e1e1e);
32
+ color: var(--hr-code-block-color, #d4d4d4);
33
+ padding: 1em;
34
+ border-radius: 0.5em;
35
+ overflow-x: auto;
36
+ font-size: 0.875em;
37
+ }
38
+ :where(.hr-check-list) {
39
+ list-style: none;
40
+ padding-left: 0;
41
+ }
42
+ :where(.hr-check-item) {
43
+ display: flex;
44
+ align-items: flex-start;
45
+ gap: 0.5em;
46
+ }
47
+ :where(.hr-check-item[data-checked]) {
48
+ text-decoration: line-through;
49
+ opacity: 0.7;
50
+ }
51
+ :where(.hr-checkbox) {
52
+ display: inline-flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ width: 1em;
56
+ height: 1em;
57
+ margin-top: 0.25em;
58
+ border: 1px solid #d1d5db;
59
+ border-radius: 0.2em;
60
+ flex-shrink: 0;
61
+ }
62
+ :where(.hr-checkbox[aria-checked="true"]) {
63
+ background: var(--hr-accent, #2563eb);
64
+ border-color: var(--hr-accent, #2563eb);
65
+ color: white;
66
+ }
67
+ :where(.hr-checkbox svg) {
68
+ width: 1em;
69
+ height: 1em;
70
+ }
71
+ :where(.hr-table) {
72
+ width: 100%;
73
+ border-collapse: collapse;
74
+ }
75
+ :where(.hr-table th, .hr-table td) {
76
+ border: 1px solid #e5e7eb;
77
+ padding: 0.5em 0.75em;
78
+ text-align: left;
79
+ }
80
+ :where(.hr-table th) {
81
+ background: var(--hr-table-header-bg, #f9fafb);
82
+ font-weight: 600;
83
+ }
84
+ :where(.hr-fallback) {
85
+ padding: 1em;
86
+ border: 1px solid #e5e7eb;
87
+ border-radius: 0.5em;
88
+ background: #f9fafb;
89
+ margin: 0.5em 0;
90
+ }
91
+ :where(.hr-fallback-label) {
92
+ font-size: 0.75em;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.05em;
95
+ color: #9ca3af;
96
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ HeadroomClient: () => HeadroomClient,
24
+ HeadroomError: () => HeadroomError
25
+ });
26
+ module.exports = __toCommonJS(src_exports);
27
+
28
+ // src/client.ts
29
+ var import_node_crypto = require("crypto");
30
+ var HeadroomError = class extends Error {
31
+ status;
32
+ code;
33
+ constructor(status, code, message) {
34
+ super(message);
35
+ this.name = "HeadroomError";
36
+ this.status = status;
37
+ this.code = code;
38
+ }
39
+ };
40
+ var HeadroomClient = class {
41
+ config;
42
+ constructor(config) {
43
+ this.config = {
44
+ ...config,
45
+ apiUrl: config.apiUrl.replace(/\/+$/, ""),
46
+ cdnUrl: config.cdnUrl?.replace(/\/+$/, "")
47
+ };
48
+ }
49
+ /** Build the full URL for a public API path */
50
+ url(path) {
51
+ return `${this.config.apiUrl}/v1/${this.config.site}${path}`;
52
+ }
53
+ /**
54
+ * Construct a full CDN URL from a stored media path.
55
+ * Media paths are stored as `/media/{site}/{mediaId}/original{ext}` in
56
+ * content responses (block props, coverUrl, field values).
57
+ *
58
+ * client.mediaUrl("/media/myblog.com/01ABC/original.jpg")
59
+ * → "https://d123.cloudfront.net/media/myblog.com/01ABC/original.jpg"
60
+ *
61
+ * Returns the path as-is if no cdnUrl is configured (useful for dev).
62
+ */
63
+ mediaUrl(path) {
64
+ if (!path) return "";
65
+ return this.config.cdnUrl ? `${this.config.cdnUrl}${path}` : path;
66
+ }
67
+ /**
68
+ * Build a signed image transform URL from a stored media path.
69
+ * Converts the `/media/...` path to `/img/...`, appends transform
70
+ * parameters, and signs with HMAC-SHA256 (matching the Go imagesign).
71
+ *
72
+ * Falls back to `mediaUrl()` when no signing secret is configured
73
+ * or when no transform options are provided.
74
+ *
75
+ * client.transformUrl("/media/myblog.com/01ABC/original.jpg", { width: 800, format: "webp" })
76
+ * → "https://cdn.example.com/img/myblog.com/01ABC/original.jpg?format=webp&w=800&sig=abc123..."
77
+ */
78
+ transformUrl(path, opts) {
79
+ if (!path) return "";
80
+ if (!this.config.imageSigningSecret || !opts || Object.keys(opts).length === 0) {
81
+ return this.mediaUrl(path);
82
+ }
83
+ const imgPath = path.replace(/^\/media\//, "/img/");
84
+ const params = new URLSearchParams();
85
+ if (opts.width) params.set("w", String(opts.width));
86
+ if (opts.height) params.set("h", String(opts.height));
87
+ if (opts.fit) params.set("fit", opts.fit);
88
+ if (opts.format) params.set("format", opts.format);
89
+ if (opts.quality) params.set("q", String(opts.quality));
90
+ params.sort();
91
+ const encoded = params.toString();
92
+ const canonical = encoded ? `${imgPath}?${encoded}` : imgPath;
93
+ const mac = (0, import_node_crypto.createHmac)("sha256", this.config.imageSigningSecret).update(canonical).digest("hex").slice(0, 32);
94
+ params.set("sig", mac);
95
+ const cdnBase = this.config.cdnUrl || "";
96
+ return `${cdnBase}${imgPath}?${params.toString()}`;
97
+ }
98
+ async fetch(path, params) {
99
+ const base = this.url(path);
100
+ const url = params?.toString() ? `${base}?${params}` : base;
101
+ const res = await fetch(url, {
102
+ headers: { "X-Headroom-Key": this.config.apiKey }
103
+ });
104
+ if (!res.ok) {
105
+ let code = "UNKNOWN";
106
+ let message = `HTTP ${res.status}`;
107
+ try {
108
+ const body = await res.json();
109
+ code = body.code || code;
110
+ message = body.error || message;
111
+ } catch {
112
+ }
113
+ throw new HeadroomError(res.status, code, message);
114
+ }
115
+ return res.json();
116
+ }
117
+ async fetchPost(path, body) {
118
+ const res = await fetch(this.url(path), {
119
+ method: "POST",
120
+ headers: {
121
+ "X-Headroom-Key": this.config.apiKey,
122
+ "Content-Type": "application/json"
123
+ },
124
+ body: JSON.stringify(body)
125
+ });
126
+ if (!res.ok) {
127
+ let code = "UNKNOWN";
128
+ let message = `HTTP ${res.status}`;
129
+ try {
130
+ const errBody = await res.json();
131
+ code = errBody.code || code;
132
+ message = errBody.error || message;
133
+ } catch {
134
+ }
135
+ throw new HeadroomError(res.status, code, message);
136
+ }
137
+ return res.json();
138
+ }
139
+ // --- Content ---
140
+ async listContent(collection, opts) {
141
+ const params = new URLSearchParams({ collection });
142
+ if (opts?.limit) params.set("limit", String(opts.limit));
143
+ if (opts?.cursor) params.set("cursor", opts.cursor);
144
+ if (opts?.before) params.set("before", String(opts.before));
145
+ if (opts?.after) params.set("after", String(opts.after));
146
+ if (opts?.sort) params.set("sort", opts.sort);
147
+ if (opts?.relatedTo) params.set("relatedTo", opts.relatedTo);
148
+ if (opts?.relField) params.set("relField", opts.relField);
149
+ return this.fetch("/content", params);
150
+ }
151
+ async getContent(contentId) {
152
+ return this.fetch(`/content/${contentId}`);
153
+ }
154
+ /**
155
+ * Fetch a single content item by slug. Uses a dedicated server-side
156
+ * endpoint for efficient lookup (no client-side pagination).
157
+ * Returns undefined if no published content matches the slug.
158
+ */
159
+ async getContentBySlug(collection, slug) {
160
+ try {
161
+ return await this.fetch(
162
+ `/content/by-slug/${encodeURIComponent(collection)}/${encodeURIComponent(slug)}`
163
+ );
164
+ } catch (e) {
165
+ if (e instanceof HeadroomError && e.status === 404) {
166
+ return void 0;
167
+ }
168
+ throw e;
169
+ }
170
+ }
171
+ async getSingleton(collection) {
172
+ return this.fetch(`/content/singleton/${collection}`);
173
+ }
174
+ /**
175
+ * Fetch multiple content items with bodies in a single request.
176
+ * Max 50 IDs per call. Items not found or unpublished are listed in `missing`.
177
+ */
178
+ async getBatchContent(ids) {
179
+ return this.fetchPost("/content/batch", { ids });
180
+ }
181
+ // --- Collections ---
182
+ async listCollections() {
183
+ return this.fetch("/collections");
184
+ }
185
+ async getCollection(name) {
186
+ return this.fetch(`/collections/${name}`);
187
+ }
188
+ // --- Block Types ---
189
+ async listBlockTypes() {
190
+ return this.fetch("/block-types");
191
+ }
192
+ // --- Version ---
193
+ async getVersion() {
194
+ const result = await this.fetch("/version");
195
+ return result.contentVersion;
196
+ }
197
+ };
198
+ // Annotate the CommonJS export names for ESM import in node:
199
+ 0 && (module.exports = {
200
+ HeadroomClient,
201
+ HeadroomError
202
+ });