levrops-contracts 1.1.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Content Contract Types
3
+ *
4
+ * Defines the structure for content contracts that describe Sanity schema.
5
+ * LevrOps is the source of truth for content models; Sanity Studio consumes
6
+ * generated schema from these contracts.
7
+ */
8
+
9
+ /**
10
+ * Supported field types in content contracts.
11
+ */
12
+ export type ContentFieldType =
13
+ | "string"
14
+ | "text"
15
+ | "number"
16
+ | "boolean"
17
+ | "date"
18
+ | "datetime"
19
+ | "url"
20
+ | "email"
21
+ | "slug"
22
+ | "richText"
23
+ | "image"
24
+ | "file"
25
+ | "array"
26
+ | "object"
27
+ | "reference";
28
+
29
+ /**
30
+ * Options for field configuration.
31
+ * Field type-specific options are provided here.
32
+ */
33
+ export interface ContentFieldOptions {
34
+ /**
35
+ * For string fields: max length
36
+ * For array fields: max items
37
+ */
38
+ maxLength?: number;
39
+ /**
40
+ * For string fields: min length
41
+ * For array fields: min items
42
+ */
43
+ minLength?: number;
44
+ /**
45
+ * For string fields: pattern/regex validation
46
+ */
47
+ pattern?: string;
48
+ /**
49
+ * For number fields: min value
50
+ */
51
+ min?: number;
52
+ /**
53
+ * For number fields: max value
54
+ */
55
+ max?: number;
56
+ /**
57
+ * For string fields: enum of allowed values
58
+ */
59
+ options?: string[];
60
+ /**
61
+ * For array fields: the type of items in the array
62
+ */
63
+ of?: ContentFieldType;
64
+ /**
65
+ * For reference fields: array of document type names this can reference
66
+ */
67
+ to?: string[];
68
+ /**
69
+ * For slug fields: the field name to use as source (default: "title")
70
+ */
71
+ source?: string;
72
+ /**
73
+ * For richText fields: allowed annotation types (e.g., ["link", "internalLink"])
74
+ */
75
+ annotations?: string[];
76
+ /**
77
+ * For richText fields: allowed decorators (e.g., ["strong", "em", "code"])
78
+ */
79
+ decorators?: string[];
80
+ }
81
+
82
+ /**
83
+ * A field definition within a content contract.
84
+ */
85
+ export interface ContentField {
86
+ /**
87
+ * Field name (camelCase)
88
+ */
89
+ name: string;
90
+ /**
91
+ * Field type
92
+ */
93
+ type: ContentFieldType;
94
+ /**
95
+ * Field title (human-readable, used in Sanity Studio)
96
+ */
97
+ title?: string;
98
+ /**
99
+ * Field description
100
+ */
101
+ description?: string;
102
+ /**
103
+ * Whether the field is required
104
+ */
105
+ required?: boolean;
106
+ /**
107
+ * Type-specific field options
108
+ */
109
+ options?: ContentFieldOptions;
110
+ }
111
+
112
+ /**
113
+ * Multi-tenant scope configuration for content contracts.
114
+ */
115
+ export interface ContentContractScope {
116
+ /**
117
+ * Tenant slugs that can use this contract.
118
+ * If empty or undefined, the contract is available to all tenants.
119
+ * Examples: ["heirloom", "studioops"]
120
+ */
121
+ tenants?: string[];
122
+ /**
123
+ * Property slugs that can use this contract.
124
+ * If empty or undefined, the contract is available to all properties.
125
+ * Examples: ["store1", "store2"]
126
+ */
127
+ properties?: string[];
128
+ /**
129
+ * Whether this contract is required for specific tenants/properties.
130
+ */
131
+ requiredFor?: {
132
+ /**
133
+ * Tenant slugs where this contract is required.
134
+ */
135
+ tenants?: string[];
136
+ /**
137
+ * Property slugs where this contract is required.
138
+ */
139
+ properties?: string[];
140
+ };
141
+ }
142
+
143
+ /**
144
+ * A content contract defining a document or object type.
145
+ */
146
+ export interface ContentContract {
147
+ /**
148
+ * Unique identifier for the contract (kebab-case, e.g., "blog-post", "hero-section")
149
+ */
150
+ id: string;
151
+ /**
152
+ * Human-readable title (used in Sanity Studio UI)
153
+ */
154
+ title: string;
155
+ /**
156
+ * Whether this is a document (can be queried standalone) or object (nested type)
157
+ */
158
+ type: "document" | "object";
159
+ /**
160
+ * Description of the content type
161
+ */
162
+ description?: string;
163
+ /**
164
+ * Fields that make up this content type
165
+ */
166
+ fields: ContentField[];
167
+ /**
168
+ * For document types: preview configuration
169
+ */
170
+ preview?: {
171
+ select: Record<string, string>;
172
+ prepare?: (selection: Record<string, unknown>) => { title?: string; subtitle?: string };
173
+ };
174
+ /**
175
+ * Multi-tenant/property scope configuration.
176
+ * If omitted, the contract is available to all tenants and properties.
177
+ */
178
+ scope?: ContentContractScope;
179
+ }
180
+
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Content Contract Utilities
3
+ *
4
+ * Utilities for filtering and managing content contracts by tenant and property.
5
+ */
6
+
7
+ import type { ContentContract } from "./types";
8
+
9
+ /**
10
+ * Filter content contracts by tenant.
11
+ *
12
+ * @param contracts - Array of content contracts to filter
13
+ * @param tenant - Tenant slug to filter by
14
+ * @returns Filtered contracts that are available to the specified tenant
15
+ */
16
+ export function filterContractsByTenant(
17
+ contracts: ContentContract[],
18
+ tenant: string
19
+ ): ContentContract[] {
20
+ return contracts.filter((contract) => {
21
+ const scope = contract.scope;
22
+
23
+ // If no scope, available to all tenants
24
+ if (!scope || !scope.tenants || scope.tenants.length === 0) {
25
+ return true;
26
+ }
27
+
28
+ // Check if tenant is in the allowed list
29
+ return scope.tenants.includes(tenant);
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Filter content contracts by property.
35
+ *
36
+ * @param contracts - Array of content contracts to filter
37
+ * @param property - Property slug to filter by
38
+ * @returns Filtered contracts that are available to the specified property
39
+ */
40
+ export function filterContractsByProperty(
41
+ contracts: ContentContract[],
42
+ property: string
43
+ ): ContentContract[] {
44
+ return contracts.filter((contract) => {
45
+ const scope = contract.scope;
46
+
47
+ // If no scope, available to all properties
48
+ if (!scope || !scope.properties || scope.properties.length === 0) {
49
+ return true;
50
+ }
51
+
52
+ // Check if property is in the allowed list
53
+ return scope.properties.includes(property);
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Filter content contracts by tenant and property.
59
+ *
60
+ * @param contracts - Array of content contracts to filter
61
+ * @param tenant - Tenant slug to filter by
62
+ * @param property - Optional property slug to filter by
63
+ * @returns Filtered contracts that are available to the specified tenant (and property if provided)
64
+ */
65
+ export function filterContracts(
66
+ contracts: ContentContract[],
67
+ tenant: string,
68
+ property?: string
69
+ ): ContentContract[] {
70
+ let filtered = filterContractsByTenant(contracts, tenant);
71
+
72
+ if (property) {
73
+ filtered = filterContractsByProperty(filtered, property);
74
+ }
75
+
76
+ return filtered;
77
+ }
78
+
79
+ /**
80
+ * Get contracts required for a specific tenant.
81
+ *
82
+ * @param contracts - Array of content contracts
83
+ * @param tenant - Tenant slug
84
+ * @returns Contracts that are required for the specified tenant
85
+ */
86
+ export function getRequiredContractsForTenant(
87
+ contracts: ContentContract[],
88
+ tenant: string
89
+ ): ContentContract[] {
90
+ return contracts.filter((contract) => {
91
+ const scope = contract.scope;
92
+ return scope?.requiredFor?.tenants?.includes(tenant) ?? false;
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Get contracts required for a specific property.
98
+ *
99
+ * @param contracts - Array of content contracts
100
+ * @param property - Property slug
101
+ * @returns Contracts that are required for the specified property
102
+ */
103
+ export function getRequiredContractsForProperty(
104
+ contracts: ContentContract[],
105
+ property: string
106
+ ): ContentContract[] {
107
+ return contracts.filter((contract) => {
108
+ const scope = contract.scope;
109
+ return scope?.requiredFor?.properties?.includes(property) ?? false;
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Group contracts by tenant.
115
+ *
116
+ * @param contracts - Array of content contracts
117
+ * @returns Object mapping tenant slugs to their available contracts
118
+ */
119
+ export function groupContractsByTenant(
120
+ contracts: ContentContract[]
121
+ ): Record<string, ContentContract[]> {
122
+ const grouped: Record<string, ContentContract[]> = {};
123
+
124
+ contracts.forEach((contract) => {
125
+ const scope = contract.scope;
126
+
127
+ // If no scope, add to all tenants (we'll handle this separately)
128
+ if (!scope || !scope.tenants || scope.tenants.length === 0) {
129
+ // Shared contracts - add to a special key
130
+ if (!grouped["_shared"]) {
131
+ grouped["_shared"] = [];
132
+ }
133
+ grouped["_shared"].push(contract);
134
+ return;
135
+ }
136
+
137
+ // Add to each tenant's list
138
+ scope.tenants.forEach((tenant) => {
139
+ if (!grouped[tenant]) {
140
+ grouped[tenant] = [];
141
+ }
142
+ grouped[tenant].push(contract);
143
+ });
144
+ });
145
+
146
+ return grouped;
147
+ }
148
+
package/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * LevrOps Contracts
3
+ *
4
+ * Main entry point for consuming content contracts and generated Sanity schemas.
5
+ */
6
+
7
+ // Export content contracts
8
+ export * from "./contracts/content";
9
+
10
+ // Export Sanity utilities
11
+ export * from "./sanity/utils";
12
+
13
+ // Re-export commonly used types
14
+ export type {
15
+ ContentContract,
16
+ ContentField,
17
+ ContentFieldOptions,
18
+ ContentContractScope,
19
+ } from "./contracts/content/types";
20
+
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "levrops-contracts",
3
+ "version": "1.1.0",
4
+ "description": "LevrOps API contracts, schemas, code generators, and Sanity content contracts",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jackmckracken/levrops-contracts.git"
9
+ },
10
+ "homepage": "https://github.com/jackmckracken/levrops-contracts#readme",
11
+ "type": "module",
12
+ "main": "./index.ts",
13
+ "types": "./index.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./index.ts",
17
+ "import": "./index.ts"
18
+ },
19
+ "./sanity/generated/schema": "./sanity/generated/schema.ts",
20
+ "./sanity/generated/schema-*": "./sanity/generated/schema-*.ts",
21
+ "./sanity/utils": "./sanity/utils.ts",
22
+ "./contracts/content": "./contracts/content/index.ts"
23
+ },
24
+ "files": [
25
+ "contracts/",
26
+ "sanity/",
27
+ "index.ts",
28
+ "README.md",
29
+ "package.json",
30
+ "tsconfig.json"
31
+ ],
32
+ "scripts": {
33
+ "sanity:codegen": "tsx tools/levrops-sanity-codegen.ts",
34
+ "sanity:check": "tsx tools/sanity-check.ts"
35
+ },
36
+ "dependencies": {
37
+ "sanity": "^3.56.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.11.24",
41
+ "tsx": "^4.7.0",
42
+ "typescript": "^5.4.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
51
+
@@ -0,0 +1,223 @@
1
+ import { defineType, defineField, string, text, number, boolean, date, datetime, url, email, slug, image, file, array, object, reference } from "sanity";
2
+
3
+ /**
4
+ * Generated Sanity Schema
5
+ *
6
+ * This file is auto-generated from content contracts in contracts/content/.
7
+ * DO NOT EDIT THIS FILE DIRECTLY.
8
+ *
9
+ * Content contracts for heirloom tenant
10
+ *
11
+ * To update the schema:
12
+ * 1. Update or add contracts in contracts/content/
13
+ * 2. Run: npm run sanity:codegen
14
+ * (or: npm run sanity:codegen -- --tenant=<tenant> --property=<property>)
15
+ *
16
+ * Generated: 2025-11-14T19:30:16.455Z
17
+ */
18
+
19
+ // Blog Post
20
+ // A blog post article
21
+ export const BlogPost = defineType({
22
+ name: "blog-post",
23
+ title: "Blog Post",
24
+ type: "document",
25
+ description: "A blog post article",
26
+ fields: [
27
+ defineField({
28
+ name: "title",
29
+ type: string(),
30
+ title: "Title",
31
+ validation: (Rule) => Rule.required().and(Rule.max(200)),
32
+
33
+ }),
34
+ defineField({
35
+ name: "slug",
36
+ type: slug({ source: "title" }),
37
+ title: "Slug",
38
+ validation: (Rule) => Rule.required(),
39
+
40
+ }),
41
+ defineField({
42
+ name: "author",
43
+ type: reference({ to: [{ type: "author" }] }),
44
+ title: "Author",
45
+ validation: (Rule) => Rule.required(),
46
+
47
+ }),
48
+ defineField({
49
+ name: "publishedAt",
50
+ type: datetime(),
51
+ title: "Published At",
52
+ validation: (Rule) => Rule.required(),
53
+
54
+ }),
55
+ defineField({
56
+ name: "excerpt",
57
+ type: text(),
58
+ title: "Excerpt",
59
+ description: "Short summary for previews",
60
+
61
+ }),
62
+ defineField({
63
+ name: "content",
64
+ type: array({
65
+ of: [{ type: "block" }],
66
+ }),
67
+ title: "Content",
68
+ validation: (Rule) => Rule.required(),
69
+
70
+ }),
71
+ defineField({
72
+ name: "tags",
73
+ type: array({
74
+ of: [string()],
75
+ }),
76
+ title: "Tags",
77
+
78
+ }),
79
+ defineField({
80
+ name: "featuredImage",
81
+ type: image(),
82
+ title: "Featured Image",
83
+
84
+ }),
85
+ ],
86
+ preview: {
87
+ select: {
88
+ "title": "title",
89
+ "author": "author.name",
90
+ "publishedAt": "publishedAt"
91
+ },
92
+ // prepare: (selection) => { ... } // Custom prepare function
93
+ },
94
+ });
95
+
96
+ // Hero Section
97
+ // Hero banner section for landing pages
98
+ export const HeroSection = defineType({
99
+ name: "hero-section",
100
+ title: "Hero Section",
101
+ type: "object",
102
+ description: "Hero banner section for landing pages",
103
+ fields: [
104
+ defineField({
105
+ name: "headline",
106
+ type: string(),
107
+ title: "Headline",
108
+ validation: (Rule) => Rule.required().and(Rule.max(100)),
109
+
110
+ }),
111
+ defineField({
112
+ name: "subheadline",
113
+ type: text(),
114
+ title: "Subheadline",
115
+
116
+ }),
117
+ defineField({
118
+ name: "image",
119
+ type: image(),
120
+ title: "Background Image",
121
+ validation: (Rule) => Rule.required(),
122
+
123
+ }),
124
+ defineField({
125
+ name: "ctaText",
126
+ type: string(),
127
+ title: "CTA Button Text",
128
+
129
+ }),
130
+ defineField({
131
+ name: "ctaLink",
132
+ type: url(),
133
+ title: "CTA Link",
134
+
135
+ }),
136
+ ],
137
+ });
138
+
139
+ // Product
140
+ // Product for Heirloom Supply catalog
141
+ export const Product = defineType({
142
+ name: "product",
143
+ title: "Product",
144
+ type: "document",
145
+ description: "Product for Heirloom Supply catalog",
146
+ fields: [
147
+ defineField({
148
+ name: "title",
149
+ type: string(),
150
+ title: "Product Name",
151
+ validation: (Rule) => Rule.required().and(Rule.max(200)),
152
+
153
+ }),
154
+ defineField({
155
+ name: "slug",
156
+ type: slug({ source: "title" }),
157
+ title: "Slug",
158
+ validation: (Rule) => Rule.required(),
159
+
160
+ }),
161
+ defineField({
162
+ name: "description",
163
+ type: array({
164
+ of: [{ type: "block" }],
165
+ }),
166
+ title: "Description",
167
+ validation: (Rule) => Rule.required(),
168
+
169
+ }),
170
+ defineField({
171
+ name: "price",
172
+ type: number(),
173
+ title: "Price",
174
+ validation: (Rule) => Rule.required().and(Rule.min(0)),
175
+
176
+ }),
177
+ defineField({
178
+ name: "images",
179
+ type: array({
180
+ of: [image()],
181
+ validation: (Rule) => Rule.min(1),
182
+ }),
183
+ title: "Product Images",
184
+ validation: (Rule) => Rule.required(),
185
+
186
+ }),
187
+ defineField({
188
+ name: "category",
189
+ type: reference({ to: [{ type: "category" }] }),
190
+ title: "Category",
191
+
192
+ }),
193
+ defineField({
194
+ name: "tags",
195
+ type: array({
196
+ of: [string()],
197
+ }),
198
+ title: "Tags",
199
+
200
+ }),
201
+ defineField({
202
+ name: "inStock",
203
+ type: boolean(),
204
+ title: "In Stock",
205
+
206
+ }),
207
+ ],
208
+ preview: {
209
+ select: {
210
+ "title": "title",
211
+ "price": "price",
212
+ "inStock": "inStock"
213
+ },
214
+ // prepare: (selection) => { ... } // Custom prepare function
215
+ },
216
+ });
217
+
218
+ // Export all schemas
219
+ export const allSchemas = [
220
+ BlogPost,
221
+ HeroSection,
222
+ Product,
223
+ ];