@stackbit/cms-sanity
Version:
Stackbit Sanity CMS Interface
295 lines (279 loc) • 11.2 kB
text/typescript
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<any> {
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);
});
}