|
| 1 | +import fs from 'fs/promises' |
| 2 | +import path from 'path' |
| 3 | +import {format} from 'prettier' |
| 4 | +import {Worker} from 'worker_threads' |
| 5 | + |
| 6 | +import { |
| 7 | + type BaseSchemaDefinition, |
| 8 | + type DocumentDefinition, |
| 9 | + type ObjectDefinition, |
| 10 | +} from '../../../types' |
| 11 | +import {type CliApiClient} from '../types' |
| 12 | +import {getCliWorkerPath} from './cliWorker' |
| 13 | + |
| 14 | +/** |
| 15 | + * A Journey schema is a server schema that is saved in the Journey API |
| 16 | + */ |
| 17 | + |
| 18 | +interface JourneySchemaWorkerData { |
| 19 | + schemasPath: string |
| 20 | + useTypeScript: boolean |
| 21 | + schemaUrl: string |
| 22 | +} |
| 23 | + |
| 24 | +type JourneySchemaWorkerResult = {type: 'success'} | {type: 'error'; error: Error} |
| 25 | + |
| 26 | +interface JourneyConfigResponse { |
| 27 | + projectId: string |
| 28 | + datasetName: string |
| 29 | + displayName: string |
| 30 | + schemaUrl: string |
| 31 | + isFirstProject: boolean // Always true for now, making it compatible with the existing getOrCreateProject |
| 32 | +} |
| 33 | + |
| 34 | +type DocumentOrObject = DocumentDefinition | ObjectDefinition |
| 35 | +type SchemaObject = BaseSchemaDefinition & { |
| 36 | + type: string |
| 37 | + fields?: SchemaObject[] |
| 38 | + of?: SchemaObject[] |
| 39 | + preview?: object |
| 40 | +} |
| 41 | + |
| 42 | +/** |
| 43 | + * Fetch a Journey schema from the Sanity schema club API and write it to disk |
| 44 | + */ |
| 45 | +export async function getAndWriteJourneySchema(data: JourneySchemaWorkerData): Promise<void> { |
| 46 | + const {schemasPath, useTypeScript, schemaUrl} = data |
| 47 | + try { |
| 48 | + const documentTypes = await fetchJourneySchema(schemaUrl) |
| 49 | + const fileExtension = useTypeScript ? 'ts' : 'js' |
| 50 | + |
| 51 | + // Write a file for each schema |
| 52 | + for (const documentType of documentTypes) { |
| 53 | + const filePath = path.join(schemasPath, `${documentType.name}.${fileExtension}`) |
| 54 | + await fs.writeFile(filePath, await assembleJourneySchemaTypeFileContent(documentType)) |
| 55 | + } |
| 56 | + // Write an index file that exports all the schemas |
| 57 | + const indexContent = await assembleJourneyIndexContent(documentTypes) |
| 58 | + await fs.writeFile(path.join(schemasPath, `index.${fileExtension}`), indexContent) |
| 59 | + } catch (error) { |
| 60 | + throw new Error(`Failed to fetch remote schema: ${error.message}`) |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Executes the `getAndWriteJourneySchema` operation within a worker thread. |
| 66 | + * |
| 67 | + * This method is designed to safely import network resources by leveraging the `--experimental-network-imports` flag. |
| 68 | + * Due to the experimental nature of this flag, its use is not recommended in the main process. Consequently, |
| 69 | + * the task is delegated to a worker thread to ensure both safety and compliance with best practices. |
| 70 | + * |
| 71 | + * The core functionality involves fetching schema definitions from our own trusted API and writing them to disk. |
| 72 | + * This includes handling both predefined and custom schemas. For custom schemas, a process ensures |
| 73 | + * that they undergo JSON parsing to remove any JavaScript code and are validated before being saved. |
| 74 | + * |
| 75 | + * Depending on the configuration, the schemas are saved as either TypeScript or JavaScript files, dictated by the `useTypeScript` flag within the `workerData`. |
| 76 | + * |
| 77 | + * @param workerData - An object containing the necessary data and flags for the worker thread, including the path to save schemas, flags indicating whether to use TypeScript, and any other relevant configuration details. |
| 78 | + * @returns A promise that resolves upon successful execution of the schema fetching and writing process or rejects if an error occurs during the operation. |
| 79 | + */ |
| 80 | +export async function getAndWriteJourneySchemaWorker( |
| 81 | + workerData: JourneySchemaWorkerData, |
| 82 | +): Promise<void> { |
| 83 | + const workerPath = await getCliWorkerPath('getAndWriteJourneySchema') |
| 84 | + return new Promise((resolve, reject) => { |
| 85 | + const worker = new Worker(workerPath, { |
| 86 | + workerData, |
| 87 | + env: { |
| 88 | + // eslint-disable-next-line no-process-env |
| 89 | + ...process.env, |
| 90 | + // Dynamic HTTPS imports are currently behind a Node flag |
| 91 | + NODE_OPTIONS: '--experimental-network-imports', |
| 92 | + NODE_NO_WARNINGS: '1', |
| 93 | + }, |
| 94 | + }) |
| 95 | + worker.on('message', (message: JourneySchemaWorkerResult) => { |
| 96 | + if (message.type === 'success') { |
| 97 | + resolve() |
| 98 | + } else { |
| 99 | + message.error.message = `Import schema worker failed: ${message.error.message}` |
| 100 | + reject(message.error) |
| 101 | + } |
| 102 | + }) |
| 103 | + worker.on('error', (error) => { |
| 104 | + error.message = `Import schema worker failed: ${error.message}` |
| 105 | + reject(error) |
| 106 | + }) |
| 107 | + worker.on('exit', (code) => { |
| 108 | + if (code !== 0) { |
| 109 | + reject(new Error(`Worker stopped with exit code ${code}`)) |
| 110 | + } |
| 111 | + }) |
| 112 | + }) |
| 113 | +} |
| 114 | + |
| 115 | +/** |
| 116 | + * Fetch a Journey config from the Sanity schema club API |
| 117 | + * |
| 118 | + * @param projectId - The slug of the Journey schema to fetch |
| 119 | + * @returns The Journey schema as an array of Sanity document or object definitions |
| 120 | + */ |
| 121 | +export async function fetchJourneyConfig( |
| 122 | + apiClient: CliApiClient, |
| 123 | + projectId: string, |
| 124 | +): Promise<JourneyConfigResponse> { |
| 125 | + if (!projectId) { |
| 126 | + throw new Error('ProjectId is required') |
| 127 | + } |
| 128 | + if (!/^[a-zA-Z0-9-]+$/.test(projectId)) { |
| 129 | + throw new Error('Invalid projectId') |
| 130 | + } |
| 131 | + try { |
| 132 | + const response: { |
| 133 | + projectId: string |
| 134 | + dataset: string |
| 135 | + displayName?: string |
| 136 | + schemaUrl: string |
| 137 | + } = await apiClient({ |
| 138 | + requireUser: true, |
| 139 | + requireProject: true, |
| 140 | + api: {projectId}, |
| 141 | + }) |
| 142 | + .config({apiVersion: 'v2024-02-23'}) |
| 143 | + .request({ |
| 144 | + method: 'GET', |
| 145 | + uri: `/journey/projects/${projectId}`, |
| 146 | + }) |
| 147 | + |
| 148 | + return { |
| 149 | + projectId: response.projectId, |
| 150 | + datasetName: response.dataset, |
| 151 | + displayName: response.displayName || 'Sanity Project', |
| 152 | + // The endpoint returns a signed URL that can be used to fetch the schema as ESM |
| 153 | + schemaUrl: response.schemaUrl, |
| 154 | + isFirstProject: true, |
| 155 | + } |
| 156 | + } catch (err) { |
| 157 | + throw new Error(`Failed to fetch remote schema config: ${projectId}`) |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +/** |
| 162 | + * Fetch a Journey schema from the Sanity schema club API |
| 163 | + * |
| 164 | + * @param projectId - The slug of the Journey schema to fetch |
| 165 | + * @returns The Journey schema as an array of Sanity document or object definitions |
| 166 | + */ |
| 167 | +async function fetchJourneySchema(schemaUrl: string): Promise<DocumentOrObject[]> { |
| 168 | + try { |
| 169 | + const response = await import(schemaUrl) |
| 170 | + return response.default |
| 171 | + } catch (err) { |
| 172 | + throw new Error(`Failed to fetch remote schema: ${schemaUrl}`) |
| 173 | + } |
| 174 | +} |
| 175 | + |
| 176 | +/** |
| 177 | + * Assemble a Journey schema type into a module export |
| 178 | + * Include the necessary imports and export the schema type as a named export |
| 179 | + * |
| 180 | + * @param schema - The Journey schema to export |
| 181 | + * @returns The Journey schema as a module export |
| 182 | + */ |
| 183 | +async function assembleJourneySchemaTypeFileContent(schemaType: DocumentOrObject): Promise<string> { |
| 184 | + const serialised = wrapSchemaTypeInHelpers(schemaType) |
| 185 | + const imports = getImports(serialised) |
| 186 | + const prettifiedSchemaType = await format(serialised, {parser: 'typescript'}) |
| 187 | + // Start file with import, then export the schema type as a named export |
| 188 | + return `${imports}\n\nexport const ${schemaType.name} = ${prettifiedSchemaType}\n` |
| 189 | +} |
| 190 | + |
| 191 | +/** |
| 192 | + * Assemble a list of Journey schema module exports into a single index file |
| 193 | + * |
| 194 | + * @param schemas - The Journey schemas to assemble into an index file |
| 195 | + * @returns The index file as a string |
| 196 | + */ |
| 197 | +function assembleJourneyIndexContent(schemas: DocumentOrObject[]): Promise<string> { |
| 198 | + const sortedSchema = schemas.slice().sort((a, b) => (a.name > b.name ? 1 : -1)) |
| 199 | + const imports = sortedSchema.map((schema) => `import { ${schema.name} } from './${schema.name}'`) |
| 200 | + const exports = sortedSchema.map((schema) => schema.name).join(',') |
| 201 | + const fileContents = `${imports.join('\n')}\n\nexport const schemaTypes = [${exports}]` |
| 202 | + return format(fileContents, {parser: 'typescript'}) |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * Get the import statements for a schema type |
| 207 | + * |
| 208 | + * @param schemaType - The schema type to get the imports for |
| 209 | + * @returns The import statements for the schema type |
| 210 | + */ |
| 211 | +function getImports(schemaType: string): string { |
| 212 | + const defaultImports = ['defineType', 'defineField'] |
| 213 | + if (schemaType.includes('defineArrayMember')) { |
| 214 | + defaultImports.push('defineArrayMember') |
| 215 | + } |
| 216 | + return `import { ${defaultImports.join(', ')} } from 'sanity'` |
| 217 | +} |
| 218 | + |
| 219 | +/** |
| 220 | + * Serialize a singleSanity schema type (signular) into a string. |
| 221 | + * Wraps the schema object in the appropriate helper function. |
| 222 | + * |
| 223 | + * @param schemaType - The schema type to serialize |
| 224 | + * @returns The schema type as a string |
| 225 | + */ |
| 226 | +/** |
| 227 | + * Serializes a single Sanity schema type into a string. |
| 228 | + * Wraps the schema object in the appropriate helper function. |
| 229 | + * |
| 230 | + * @param schemaType - The schema type to serialize |
| 231 | + * @param root - Whether the schemaType is the root object |
| 232 | + * @returns The serialized schema type as a string |
| 233 | + */ |
| 234 | +export function wrapSchemaTypeInHelpers(schemaType: SchemaObject, root: boolean = true): string { |
| 235 | + if (root) { |
| 236 | + return generateSchemaDefinition(schemaType, 'defineType') |
| 237 | + } else if (schemaType.type === 'array') { |
| 238 | + return `${generateSchemaDefinition(schemaType, 'defineField')},` |
| 239 | + } |
| 240 | + return `${generateSchemaDefinition(schemaType, 'defineField')},` |
| 241 | + |
| 242 | + function generateSchemaDefinition( |
| 243 | + object: SchemaObject, |
| 244 | + definitionType: 'defineType' | 'defineField', |
| 245 | + ): string { |
| 246 | + const {fields, preview, of, ...otherProperties} = object |
| 247 | + |
| 248 | + const serializedProps = serialize(otherProperties) |
| 249 | + const fieldsDef = |
| 250 | + fields && `fields: [${fields.map((f) => wrapSchemaTypeInHelpers(f, false)).join('')}],` |
| 251 | + const ofDef = of && `of: [${of.map((f) => `defineArrayMember({${serialize(f)}})`).join(',')}],` |
| 252 | + const previewDef = preview && `preview: {${serialize(preview)}}` |
| 253 | + |
| 254 | + const combinedDefinitions = [serializedProps, fieldsDef, ofDef, previewDef] |
| 255 | + .filter(Boolean) |
| 256 | + .join(',') |
| 257 | + return `${definitionType}({ ${combinedDefinitions} })` |
| 258 | + } |
| 259 | + |
| 260 | + function serialize(obj: object) { |
| 261 | + return Object.entries(obj) |
| 262 | + .map(([key, value]) => { |
| 263 | + if (key === 'prepare') { |
| 264 | + return `${value.toString()}` |
| 265 | + } |
| 266 | + if (typeof value === 'string') { |
| 267 | + return `${key}: "${value}"` |
| 268 | + } |
| 269 | + if (typeof value === 'object') { |
| 270 | + return `${key}: ${JSON.stringify(value)}` |
| 271 | + } |
| 272 | + return `${key}: ${value}` |
| 273 | + }) |
| 274 | + .join(',') |
| 275 | + } |
| 276 | +} |
0 commit comments