UNPKG

sanity

Version:

Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches

266 lines (226 loc) • 8.7 kB
import {type CliCommandDefinition} from '@sanity/cli' import { type IdentifiedSanityDocumentStub, type MultipleMutationResult, type Mutation, type SanityClient, } from '@sanity/client' import {uuid} from '@sanity/uuid' import chokidar from 'chokidar' import execa from 'execa' import fs from 'fs/promises' import json5 from 'json5' import {isEqual, isPlainObject, noop} from 'lodash' import os from 'os' import path from 'path' type MutationOperationName = 'create' | 'createOrReplace' | 'createIfNotExists' interface CreateFlags { dataset?: string replace?: boolean missing?: boolean watch?: boolean json5?: boolean id?: string } const helpText = ` Options --replace On duplicate document IDs, replace existing document with specified document(s) --missing On duplicate document IDs, don't modify the target document(s) --watch Write the documents whenever the target file or buffer changes --json5 Use JSON5 file type to allow a "simplified" version of JSON --id <id> Specify a document ID to use. Will fetch remote document ID and populate editor. --dataset NAME to override dataset Examples # Create the document specified in "myDocument.json". sanity documents create myDocument.json # Open configured $EDITOR and create the specified document(s) sanity documents create # Fetch document with the ID "myDocId" and open configured $EDITOR with the # current document content (if any). Replace document with the edited version # when the editor closes sanity documents create --id myDocId --replace # Open configured $EDITOR and replace the document with the given content # on each save. Use JSON5 file extension and parser for simplified syntax. sanity documents create --id myDocId --watch --replace --json5 ` const createDocumentsCommand: CliCommandDefinition<CreateFlags> = { name: 'create', group: 'documents', signature: '[FILE]', helpText, description: 'Create one or more documents', // eslint-disable-next-line complexity action: async (args, context) => { const {apiClient, output} = context const {replace, missing, watch, id, dataset} = args.extOptions const [file] = args.argsWithoutOptions const useJson5 = args.extOptions.json5 const client = dataset ? apiClient().clone().config({dataset}) : apiClient() if (replace && missing) { throw new Error('Cannot use both --replace and --missing') } if (id && file) { throw new Error('Cannot use --id when specifying a file path') } let operation: MutationOperationName = 'create' if (replace || missing) { operation = replace ? 'createOrReplace' : 'createIfNotExists' } if (file) { const contentPath = path.resolve(process.cwd(), file) const content = json5.parse(await fs.readFile(contentPath, 'utf8')) const result = await writeDocuments(content, operation, client) output.print(getResultMessage(result, operation)) return } // Create a temporary file and use that as source, opening an editor on it const docId = id || uuid() const ext = useJson5 ? 'json5' : 'json' const tmpFile = path.join(os.tmpdir(), 'sanity-cli', `${docId}.${ext}`) const stringify = useJson5 ? json5.stringify : JSON.stringify const defaultValue = (id && (await client.getDocument(id))) || {_id: docId, _type: 'specify-me'} await fs.mkdir(path.join(os.tmpdir(), 'sanity-cli'), {recursive: true}) await fs.writeFile(tmpFile, stringify(defaultValue, null, 2), 'utf8') const editor = getEditor() if (watch) { // If we're in watch mode, we want to run the creation on each change (if it validates) registerUnlinkOnSigInt(tmpFile) output.print(`Watch mode: ${tmpFile}`) output.print('Watch mode: Will write documents on each save.') output.print('Watch mode: Press Ctrl + C to cancel watch mode.') chokidar.watch(tmpFile).on('change', () => { output.print('') return readAndPerformCreatesFromFile(tmpFile) }) execa(editor.bin, editor.args.concat(tmpFile), {stdio: 'inherit'}) } else { // While in normal mode, we just want to wait for the editor to close and run the thing once execa.sync(editor.bin, editor.args.concat(tmpFile), {stdio: 'inherit'}) await readAndPerformCreatesFromFile(tmpFile) await fs.unlink(tmpFile).catch(noop) } async function readAndPerformCreatesFromFile(filePath: string) { let content try { content = json5.parse(await fs.readFile(filePath, 'utf8')) } catch (err) { output.error(`Failed to read input: ${err.message}`) return } if (isEqual(content, defaultValue)) { output.print('Value not modified, doing nothing.') output.print('Modify document to trigger creation.') return } try { const writeResult = await writeDocuments(content, operation, client) output.print(getResultMessage(writeResult, operation)) } catch (err) { output.error(`Failed to write documents: ${err.message}`) if (err.message.includes('already exists')) { output.error('Perhaps you want to use `--replace` or `--missing`?') } } } }, } function registerUnlinkOnSigInt(tmpFile: string) { process.on('SIGINT', async () => { await fs.unlink(tmpFile).catch(noop) // eslint-disable-next-line no-process-exit process.exit(130) }) } function writeDocuments( documents: {_id?: string; _type: string} | {_id?: string; _type: string}[], operation: MutationOperationName, client: SanityClient, ) { const docs = Array.isArray(documents) ? documents : [documents] if (docs.length === 0) { throw new Error('No documents provided') } const mutations = docs.map((doc, index): Mutation => { validateDocument(doc, index, docs) if (operation === 'create') { return {create: doc} } if (operation === 'createIfNotExists') { if (isIdentifiedSanityDocument(doc)) { return {createIfNotExists: doc} } throw new Error(`Missing required _id attribute for ${operation}`) } if (operation === 'createOrReplace') { if (isIdentifiedSanityDocument(doc)) { return {createOrReplace: doc} } throw new Error(`Missing required _id attribute for ${operation}`) } throw new Error(`Unsupported operation ${operation}`) }) return client.transaction(mutations).commit() } function validateDocument(doc: unknown, index: number, arr: unknown[]) { const isSingle = arr.length === 1 if (!isPlainObject(doc)) { throw new Error(getErrorMessage('must be an object', index, isSingle)) } if (!isSanityDocumentish(doc)) { throw new Error(getErrorMessage('must have a `_type` property of type string', index, isSingle)) } } function isSanityDocumentish(doc: unknown): doc is {_type: string} { return ( doc !== null && typeof doc === 'object' && '_type' in doc && typeof (doc as any)._type === 'string' ) } function isIdentifiedSanityDocument(doc: unknown): doc is IdentifiedSanityDocumentStub { return isSanityDocumentish(doc) && '_id' in doc } function getErrorMessage(message: string, index: number, isSingle: boolean): string { return isSingle ? `Document ${message}` : `Document at index ${index} ${message}` } function getResultMessage( result: MultipleMutationResult, operation: MutationOperationName, ): string { const joiner = '\n - ' if (operation === 'createOrReplace') { return `Upserted:\n - ${result.results.map((res) => res.id).join(joiner)}` } if (operation === 'create') { return `Created:\n - ${result.results.map((res) => res.id).join(joiner)}` } // "Missing" (createIfNotExists) const created: string[] = [] const skipped: string[] = [] for (const res of result.results) { if (res.operation === 'update') { skipped.push(res.id) } else { created.push(res.id) } } if (created.length > 0 && skipped.length > 0) { return [ `Created:\n - ${created.join(joiner)}`, `Skipped (already exists):${joiner}${skipped.join(joiner)}`, ].join('\n\n') } else if (created.length > 0) { return `Created:\n - ${created.join(joiner)}` } return `Skipped (already exists):\n - ${skipped.join(joiner)}` } function getEditor() { const defaultEditor = /^win/.test(process.platform) ? 'notepad' : 'vim' // eslint-disable-next-line no-process-env const editor = process.env.VISUAL || process.env.EDITOR || defaultEditor const args = editor.split(/\s+/) const bin = args.shift() || '' return {bin, args} } export default createDocumentsCommand