import * as childProcess from 'child_process'; import * as path from 'path'; import _ from 'lodash'; import fse from 'fs-extra'; import { deepMap } from '@stackbit/utils'; import * as StackbitTypes from '@stackbit/types'; export async function fetchSchemaLegacy({ studioPath }: { studioPath: string }) { console.log('fetch sanity schema legacy'); const getSanitySchema = require('@sanity/core/lib/actions/graphql/getSanitySchema'); const internalSchema = getSanitySchema(studioPath); const models = _.get(internalSchema, '_source.types', []); return { models: serializeValidation(models) }; } export async function fetchSchemaV3({ studioPath }: { studioPath: string }) { console.log('fetch sanity schema v3'); // use internal Sanity API to get the schema const internalApiFileName = './node_modules/sanity/lib/_internal/cli/threads/getGraphQLAPIs.js'; const internalApiPath = path.resolve(studioPath, internalApiFileName); if (!(await fse.pathExists(internalApiPath))) { throw new Error('Could not find Sanity file: ' + internalApiPath); } const internalApiPatchedFileName = path.join(path.dirname(internalApiFileName), 'getGraphQLAPIs.patched.js'); const internalApiPatchedPath = path.resolve(studioPath, internalApiPatchedFileName); // if file doesn't exist, copy it and patch it let data; if (!(await fse.pathExists(internalApiPatchedPath))) { console.log('patching sanity studio file at path: ' + internalApiPath); await fse.copyFile(internalApiPath, internalApiPatchedPath); data = await fse.readFile(internalApiPatchedPath, 'utf8'); // monkey patch the Sanity internal API to expose getStudioConfig // and to not throw an error or run code on import data = data.replace(/throw new Error\(".*?"\);/g, 'void(0);'); data = data.replace(/\ngetGraphQLAPIsForked\(.*?\);/g, '\nvoid(0);'); if (data.includes('getStudioConfig')) { data += `\n\nexports.getStudioConfig = getStudioConfig;`; } else if (data.includes('getStudioWorkspaces.getStudioWorkspaces')) { data += `\n\nexports.getStudioWorkspaces = getStudioWorkspaces.getStudioWorkspaces;`; } await fse.writeFile(internalApiPatchedPath, data); } else { console.log('sanity studio file already patched at path: ' + internalApiPath); data = await fse.readFile(internalApiPatchedPath, 'utf8'); } let methodName; if (data.includes('getStudioConfig')) { methodName = 'getStudioConfig'; } else if (data.includes('getStudioWorkspaces.getStudioWorkspaces')) { methodName = 'getStudioWorkspaces'; } else { throw new Error('Could not patch Sanity file: ' + internalApiPath); } // load the patched file and use `getStudioConfig` to get the schema const getStudioConfig = require(internalApiPatchedPath)[methodName]; const internalConfig = await getStudioConfig({ basePath: studioPath }); const internalSource = internalConfig?.[0]?.unstable_sources?.[0] ?? internalConfig?.[0]; const types = internalSource?.schema?._original?.types; return { projectId: internalSource.projectId, dataset: internalSource.dataset, title: internalSource.title, models: serializeValidation(types) }; } export async function fetchSchema({ studioPath }: { studioPath: string }) { console.log('sanity studio path: ' + studioPath); const candidates = ['sanity.config.js', 'sanity.config.jsx', 'sanity.config.ts', 'sanity.config.tsx']; for (const sanityConfigFileName of candidates) { const sanityConfigPath = path.resolve(studioPath, sanityConfigFileName); if (await fse.pathExists(sanityConfigPath)) { return fetchSchemaV3({ studioPath }); } } return fetchSchemaLegacy({ studioPath }); } /** * Serializes Sanity validation functions into JSON * https://www.sanity.io/docs/validation * * Supported validation functions: * Rule.required() * Rule.integer() * Rule.min(n) * Rule.max(n) * Rule.length(n) * * Rule => Rule.required() * => { isRequired: true } * * // Also supports array of rules as defined in Sanity docs: * Rule => [ * Rule.required(), * Rule.integer() * ] * => { isRequired: true, isInteger: true } * Rule => Rule.required().min(10).max(80) * => { isRequired: true, min: 10, max: 80 } * * @param models * @return {*} */ function serializeValidation(models: any) { const Rule = require('@sanity/validation').Rule; return deepMap(models, (value, keyPath, objectStack) => { if (_.isFunction(value) && _.last(keyPath) === 'validation') { const field = _.last(objectStack); const rules = _.castArray(field.validation(new Rule())); const isRequired = _.some(rules, (rule) => rule.isRequired()); const _rules = _.flatten(_.map(rules, '_rules')); const isInteger = _.some(_rules, (rule) => rule.flag === 'integer') || undefined; const min = _.get( _.find(_rules, (rule) => rule.flag === 'min'), 'constraint' ); const max = _.get( _.find(_rules, (rule) => rule.flag === 'max'), 'constraint' ); const length = _.get( _.find(_rules, (rule) => rule.flag === 'length'), 'constraint' ); return _.omitBy( { isRequired, isInteger, min, max, length }, _.isNil ); } return value; }); } /* async function fetchSchema({studioPath}) { const sanityJsonPath = path.resolve(studioPath, 'sanity.json'); console.log('sanity studio config path: ' + sanityJsonPath); const exists = await fse.pathExists(sanityJsonPath); if (!exists) { return Promise.reject('could not find sanity.json file'); } const sanityJson = await parseFile(sanityJsonPath); const parts = _.get(sanityJson, 'parts'); const schemasPart = _.find(parts, {name: 'part:@sanity/base/schema'}); let schemaPath = './schemas/schema.js'; if (schemasPart) { schemaPath = schemasPart.path; } const absSchemaPath = path.join(studioPath, schemaPath); console.log('sanity studio schema path: ' + absSchemaPath); require = require('esm')(module/!*, options*!/); const registerLoader = require('@sanity/plugin-loader'); registerLoader({basePath: studioPath}); const internalSchema = require(absSchemaPath); return { models: _.get(internalSchema, 'default._source.types', []) }; } */ export interface FetchSchemaOptions { studioPath: string; repoPath: string; nodePath?: string; spawnRunner?: StackbitTypes.UserCommandSpawner; logger?: StackbitTypes.Logger; } export function spawnFetchSchema({ studioPath, nodePath, repoPath, spawnRunner, logger }: FetchSchemaOptions): Promise { logger?.debug(`[sanity-schema-fetcher] spawn fetch schema, studioPath: ${studioPath}, repoPath: ${repoPath} using spawnRunner: ${!!spawnRunner}`); return new Promise((resolve, reject) => { let done = false; let errOutput = ''; const buffers = { stdout: '', stderr: '' }; let schema: any = null; const finish = (code: number | null) => { if (done) { return; } done = true; flush(); if (code === 0) { resolve(schema); } else { reject(errOutput); } }; const writeLine = (type: string, line: string) => { if (_.isEmpty(_.trim(line))) { return; } if (type === 'stderr') { logger?.error(`[sanity-schema-fetcher] stderr: ${line}`); } else if (line.indexOf('SCHEMA_OUTPUT') !== -1) { logger?.debug(`[sanity-schema-fetcher] got schema`); schema = JSON.parse(line.match(/SCHEMA_OUTPUT:(.*)/)![1]!); } else { logger?.debug(`[sanity-schema-fetcher] stdout: ${line}`); } }; const handler = (type: keyof typeof buffers, data: string) => { const lines = _.split(data, '\n'); if (buffers[type]) { lines[0] = buffers[type] + lines[0]; buffers[type] = ''; } if (_.last(lines) !== '') { buffers[type] = lines.pop() || ''; } if (type === 'stderr') { errOutput += data; } _.forEach(lines, (line) => { writeLine(type, line); }); }; const flush = () => { _.forEach(buffers, (line, key) => { writeLine(key, line); }); }; const proc = spawnRunner?.({ command: 'node', args: [__filename, studioPath], cwd: studioPath, env: { NODE_PATH: nodePath || `${path.join(studioPath, 'node_modules')}:${path.join(repoPath, 'node_modules')}` } }) ?? // To debug the subprocess add --inspect=9229 // args: ['--inspect=9229', __filename, studioPath] childProcess.spawn('node', [__filename, studioPath], { cwd: studioPath, env: _.assign({}, process.env, { NODE_PATH: nodePath || `${path.join(studioPath, 'node_modules')}:${path.join(repoPath, 'node_modules')}` }) }); proc.stdout.on('data', _.partial(handler, 'stdout')); proc.stderr.on('data', _.partial(handler, 'stderr')); proc.on('error', (error) => { logger?.debug(`[sanity-schema-fetcher] error: ${error}`); }); proc.on('close', (code) => { if (code != 0) { logger?.debug(`[sanity-schema-fetcher] closed with code: ${code}`); } finish(code); }); proc.on('exit', (code) => { if (code != 0) { logger?.debug(`[sanity-schema-fetcher] exited with code: ${code}`); } finish(code); }); }); } if (require.main === module) { const studioPath = process.argv[2]; if (!studioPath) { console.error('[sanity-schema-fetcher] missing studio path'); process.exit(1); } console.log(`[sanity-schema-fetcher] fetch schema, studioPath: ${studioPath}`); fetchSchema({ studioPath }) .then((schema) => { console.log(`[sanity-schema-fetcher] done fetching`); process.stdout.write(`SCHEMA_OUTPUT:${JSON.stringify(schema)}`, () => { process.exit(0); }); }) .catch((err) => { console.error(`[sanity-schema-fetcher] error fetching, error: ${err.message}`, err.stack); process.exit(1); }); }