From 0d4e40d94a14c109067f82161867be98fa1d7717 Mon Sep 17 00:00:00 2001 From: Carolina Gonzalez Date: Wed, 5 Feb 2025 11:21:14 -0500 Subject: [PATCH] feat(cli): add non-studio app template (#8394) --- packages/@sanity/cli/.depcheckrc.json | 2 + .../init-project/bootstrapLocalTemplate.ts | 52 +++++++----- .../actions/init-project/createCliConfig.ts | 52 ++---------- .../init-project/createCoreAppCliConfig.ts | 23 +++++ .../init-project/createPackageManifest.ts | 2 +- .../init-project/createStudioConfig.ts | 31 +------ .../init-project/determineCoreAppTemplate.ts | 13 +++ .../src/actions/init-project/initProject.ts | 23 ++++- .../actions/init-project/processTemplate.ts | 55 ++++++++++++ .../actions/init-project/templates/coreApp.ts | 31 +++++++ .../actions/init-project/templates/index.ts | 2 + packages/@sanity/cli/src/types.ts | 10 +++ .../cli/templates/core-app/src/App.tsx | 26 ++++++ packages/@sanity/cli/test/init.test.ts | 67 ++++++++------- .../cli/actions/build/buildAction.ts | 10 ++- .../_internal/cli/commands/app/appGroup.ts | 10 +++ .../cli/commands/app/buildCommand.ts | 52 ++++++++++++ .../_internal/cli/commands/app/devCommand.ts | 58 +++++++++++++ .../cli/commands/app/startCommand.ts | 84 +++++++++++++++++++ .../src/_internal/cli/commands/index.ts | 8 ++ .../_internal/cli/server/buildStaticFiles.ts | 14 +++- .../src/_internal/cli/server/devServer.ts | 11 ++- .../_internal/cli/server/getEntryModule.ts | 21 ++++- .../src/_internal/cli/server/getViteConfig.ts | 6 +- .../src/_internal/cli/server/previewServer.ts | 7 +- .../_internal/cli/server/renderDocument.tsx | 29 +++++-- .../src/_internal/cli/server/runtime.ts | 27 ++++-- .../vite/plugin-sanity-build-entries.ts | 5 +- .../cli/util/checkRequiredDependencies.ts | 7 ++ .../sanity/src/_internal/cli/util/servers.ts | 7 ++ .../src/core/components/BasicDocument.tsx | 49 +++++++++++ packages/sanity/src/core/components/index.ts | 1 + 32 files changed, 641 insertions(+), 154 deletions(-) create mode 100644 packages/@sanity/cli/src/actions/init-project/createCoreAppCliConfig.ts create mode 100644 packages/@sanity/cli/src/actions/init-project/determineCoreAppTemplate.ts create mode 100644 packages/@sanity/cli/src/actions/init-project/processTemplate.ts create mode 100644 packages/@sanity/cli/src/actions/init-project/templates/coreApp.ts create mode 100644 packages/@sanity/cli/templates/core-app/src/App.tsx create mode 100644 packages/sanity/src/_internal/cli/commands/app/appGroup.ts create mode 100644 packages/sanity/src/_internal/cli/commands/app/buildCommand.ts create mode 100644 packages/sanity/src/_internal/cli/commands/app/devCommand.ts create mode 100644 packages/sanity/src/_internal/cli/commands/app/startCommand.ts create mode 100644 packages/sanity/src/core/components/BasicDocument.tsx diff --git a/packages/@sanity/cli/.depcheckrc.json b/packages/@sanity/cli/.depcheckrc.json index 90cce94cb1c..275fe891d83 100644 --- a/packages/@sanity/cli/.depcheckrc.json +++ b/packages/@sanity/cli/.depcheckrc.json @@ -13,6 +13,8 @@ "@portabletext/types", "slug", "@sanity/asset-utils", + "@sanity/sdk", + "@sanity/sdk-react", "styled-components", "sanity-plugin-hotspot-array", "react-icons", diff --git a/packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts b/packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts index a635d5c792d..73c3df6205c 100644 --- a/packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts +++ b/packages/@sanity/cli/src/actions/init-project/bootstrapLocalTemplate.ts @@ -10,8 +10,10 @@ import {copy} from '../../util/copy' import {getAndWriteJourneySchemaWorker} from '../../util/journeyConfig' import {resolveLatestVersions} from '../../util/resolveLatestVersions' import {createCliConfig} from './createCliConfig' +import {createCoreAppCliConfig} from './createCoreAppCliConfig' import {createPackageManifest} from './createPackageManifest' import {createStudioConfig, type GenerateConfigOptions} from './createStudioConfig' +import {determineCoreAppTemplate} from './determineCoreAppTemplate' import {type ProjectTemplate} from './initProject' import templates from './templates' import {updateInitialTemplateMetadata} from './updateInitialTemplateMetadata' @@ -36,9 +38,9 @@ export async function bootstrapLocalTemplate( const {apiClient, cliRoot, output} = context const templatesDir = path.join(cliRoot, 'templates') const {outputPath, templateName, useTypeScript, packageName, variables} = opts - const {projectId} = variables const sourceDir = path.join(templatesDir, templateName) const sharedDir = path.join(templatesDir, 'shared') + const isCoreAppTemplate = determineCoreAppTemplate(templateName) // Check that we have a template info file (dependencies, plugins etc) const template = templates[templateName] @@ -81,15 +83,16 @@ export async function bootstrapLocalTemplate( // Resolve latest versions of Sanity-dependencies spinner = output.spinner('Resolving latest module versions').start() const dependencyVersions = await resolveLatestVersions({ - ...studioDependencies.dependencies, - ...studioDependencies.devDependencies, + ...(isCoreAppTemplate ? {} : studioDependencies.dependencies), + ...(isCoreAppTemplate ? {} : studioDependencies.devDependencies), ...(template.dependencies || {}), + ...(template.devDependencies || {}), }) spinner.succeed() // Use the resolved version for the given dependency const dependencies = Object.keys({ - ...studioDependencies.dependencies, + ...(isCoreAppTemplate ? {} : studioDependencies.dependencies), ...template.dependencies, }).reduce( (deps, dependency) => { @@ -100,7 +103,7 @@ export async function bootstrapLocalTemplate( ) const devDependencies = Object.keys({ - ...studioDependencies.devDependencies, + ...(isCoreAppTemplate ? {} : studioDependencies.devDependencies), ...template.devDependencies, }).reduce( (deps, dependency) => { @@ -116,32 +119,41 @@ export async function bootstrapLocalTemplate( name: packageName, dependencies, devDependencies, + scripts: template.scripts, }) // ...and a studio config (`sanity.config.[ts|js]`) - const studioConfig = await createStudioConfig({ + const studioConfig = createStudioConfig({ template: template.configTemplate, variables, }) // ...and a CLI config (`sanity.cli.[ts|js]`) - const cliConfig = await createCliConfig({ - projectId: variables.projectId, - dataset: variables.dataset, - autoUpdates: variables.autoUpdates, - }) + const cliConfig = isCoreAppTemplate + ? createCoreAppCliConfig({appLocation: template.appLocation!}) + : createCliConfig({ + projectId: variables.projectId, + dataset: variables.dataset, + autoUpdates: variables.autoUpdates, + }) // Write non-template files to disc const codeExt = useTypeScript ? 'ts' : 'js' - await Promise.all([ - writeFileIfNotExists(`sanity.config.${codeExt}`, studioConfig), - writeFileIfNotExists(`sanity.cli.${codeExt}`, cliConfig), - writeFileIfNotExists('package.json', packageManifest), - writeFileIfNotExists( - 'eslint.config.mjs', - `import studio from '@sanity/eslint-config-studio'\n\nexport default [...studio]\n`, - ), - ]) + await Promise.all( + [ + ...[ + isCoreAppTemplate + ? Promise.resolve(null) + : writeFileIfNotExists(`sanity.config.${codeExt}`, studioConfig), + ], + writeFileIfNotExists(`sanity.cli.${codeExt}`, cliConfig), + writeFileIfNotExists('package.json', packageManifest), + writeFileIfNotExists( + 'eslint.config.mjs', + `import studio from '@sanity/eslint-config-studio'\n\nexport default [...studio]\n`, + ), + ].filter(Boolean), + ) debug('Updating initial template metadata') await updateInitialTemplateMetadata(apiClient, variables.projectId, `cli-${templateName}`) diff --git a/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts b/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts index abd1e5c8c92..caa6bbb30d5 100644 --- a/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts +++ b/packages/@sanity/cli/src/actions/init-project/createCliConfig.ts @@ -1,6 +1,4 @@ -import traverse from '@babel/traverse' -import {parse, print} from 'recast' -import * as parser from 'recast/parsers/typescript' +import {processTemplate} from './processTemplate' const defaultTemplate = ` import {defineCliConfig} from 'sanity/cli' @@ -25,49 +23,9 @@ export interface GenerateCliConfigOptions { } export function createCliConfig(options: GenerateCliConfigOptions): string { - const variables = options - const template = defaultTemplate.trimStart() - const ast = parse(template, {parser}) - - traverse(ast, { - StringLiteral: { - enter({node}) { - const value = node.value - if (!value.startsWith('%') || !value.endsWith('%')) { - return - } - const variableName = value.slice(1, -1) as keyof GenerateCliConfigOptions - if (!(variableName in variables)) { - throw new Error(`Template variable '${value}' not defined`) - } - const newValue = variables[variableName] - /* - * although there are valid non-strings in our config, - * they're not in StringLiteral nodes, so assume undefined - */ - node.value = typeof newValue === 'string' ? newValue : '' - }, - }, - Identifier: { - enter(path) { - if (!path.node.name.startsWith('__BOOL__')) { - return - } - const variableName = path.node.name.replace( - /^__BOOL__(.+?)__$/, - '$1', - ) as keyof GenerateCliConfigOptions - if (!(variableName in variables)) { - throw new Error(`Template variable '${variableName}' not defined`) - } - const value = variables[variableName] - if (typeof value !== 'boolean') { - throw new Error(`Expected boolean value for '${variableName}'`) - } - path.replaceWith({type: 'BooleanLiteral', value}) - }, - }, + return processTemplate({ + template: defaultTemplate, + variables: options, + includeBooleanTransform: true, }) - - return print(ast, {quote: 'single'}).code } diff --git a/packages/@sanity/cli/src/actions/init-project/createCoreAppCliConfig.ts b/packages/@sanity/cli/src/actions/init-project/createCoreAppCliConfig.ts new file mode 100644 index 00000000000..177df73b395 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init-project/createCoreAppCliConfig.ts @@ -0,0 +1,23 @@ +import {processTemplate} from './processTemplate' + +const defaultCoreAppTemplate = ` +import {defineCliConfig} from 'sanity/cli' + +export default defineCliConfig({ + __experimental_coreAppConfiguration: { + appLocation: '%appLocation%' + }, +}) +` + +export interface GenerateCliConfigOptions { + organizationId?: string + appLocation: string +} + +export function createCoreAppCliConfig(options: GenerateCliConfigOptions): string { + return processTemplate({ + template: defaultCoreAppTemplate, + variables: options, + }) +} diff --git a/packages/@sanity/cli/src/actions/init-project/createPackageManifest.ts b/packages/@sanity/cli/src/actions/init-project/createPackageManifest.ts index c1d81ce1c73..78370174427 100644 --- a/packages/@sanity/cli/src/actions/init-project/createPackageManifest.ts +++ b/packages/@sanity/cli/src/actions/init-project/createPackageManifest.ts @@ -29,7 +29,7 @@ export function createPackageManifest( main: 'package.json', keywords: ['sanity'], - scripts: { + scripts: data.scripts || { 'dev': 'sanity dev', 'start': 'sanity start', 'build': 'sanity build', diff --git a/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts b/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts index c5d29795ec6..aae87453e9e 100644 --- a/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts +++ b/packages/@sanity/cli/src/actions/init-project/createStudioConfig.ts @@ -1,6 +1,4 @@ -import traverse from '@babel/traverse' -import {parse, print} from 'recast' -import * as parser from 'recast/parsers/typescript' +import {processTemplate} from './processTemplate' const defaultTemplate = ` import {defineConfig} from 'sanity' @@ -47,29 +45,8 @@ export function createStudioConfig(options: GenerateConfigOptions): string { return options.template(variables).trimStart() } - const template = (options.template || defaultTemplate).trimStart() - const ast = parse(template, {parser}) - traverse(ast, { - StringLiteral: { - enter({node}) { - const value = node.value - if (!value.startsWith('%') || !value.endsWith('%')) { - return - } - - const variableName = value.slice(1, -1) as keyof GenerateConfigOptions['variables'] - if (!(variableName in variables)) { - throw new Error(`Template variable '${value}' not defined`) - } - const newValue = variables[variableName] - /* - * although there are valid non-strings in our config, - * they're not in this template, so assume undefined - */ - node.value = typeof newValue === 'string' ? newValue : '' - }, - }, + return processTemplate({ + template: options.template || defaultTemplate, + variables, }) - - return print(ast, {quote: 'single'}).code } diff --git a/packages/@sanity/cli/src/actions/init-project/determineCoreAppTemplate.ts b/packages/@sanity/cli/src/actions/init-project/determineCoreAppTemplate.ts new file mode 100644 index 00000000000..7ae7b42886d --- /dev/null +++ b/packages/@sanity/cli/src/actions/init-project/determineCoreAppTemplate.ts @@ -0,0 +1,13 @@ +const coreAppTemplates = ['core-app'] + +/** + * Determine if a given template is a studio template. + * This function may need to be more robust once we + * introduce remote templates, for example. + * + * @param templateName - Name of the template + * @returns boolean indicating if the template is a studio template + */ +export function determineCoreAppTemplate(templateName: string): boolean { + return coreAppTemplates.includes(templateName) +} diff --git a/packages/@sanity/cli/src/actions/init-project/initProject.ts b/packages/@sanity/cli/src/actions/init-project/initProject.ts index e100bed2e2a..ff1764e9aff 100644 --- a/packages/@sanity/cli/src/actions/init-project/initProject.ts +++ b/packages/@sanity/cli/src/actions/init-project/initProject.ts @@ -49,6 +49,7 @@ import {createProject} from '../project/createProject' import {bootstrapLocalTemplate} from './bootstrapLocalTemplate' import {bootstrapRemoteTemplate} from './bootstrapRemoteTemplate' import {type GenerateConfigOptions} from './createStudioConfig' +import {determineCoreAppTemplate} from './determineCoreAppTemplate' import {absolutify, validateEmptyPath} from './fsUtils' import {tryGitInit} from './git' import {promptForDatasetName} from './promptForDatasetName' @@ -97,6 +98,8 @@ export interface ProjectTemplate { importPrompt?: string configTemplate?: string | ((variables: GenerateConfigOptions['variables']) => string) typescriptOnly?: boolean + appLocation?: string + scripts?: Record } export interface ProjectOrganization { @@ -271,6 +274,9 @@ export default async function initSanity( print('') const flags = await prepareFlags() + // skip project / dataset prompting + const isCoreAppTemplate = cliFlags.template ? determineCoreAppTemplate(cliFlags.template) : false // Default to false + // We're authenticated, now lets select or create a project const {projectId, displayName, isFirstProject, datasetName, schemaUrl} = await getProjectDetails() @@ -655,11 +661,15 @@ export default async function initSanity( const isCurrentDir = outputPath === process.cwd() if (isCurrentDir) { print(`\n${chalk.green('Success!')} Now, use this command to continue:\n`) - print(`${chalk.cyan(devCommand)} - to run Sanity Studio\n`) + print( + `${chalk.cyan(devCommand)} - to run ${isCoreAppTemplate ? 'your Sanity application' : 'Sanity Studio'}\n`, + ) } else { print(`\n${chalk.green('Success!')} Now, use these commands to continue:\n`) print(`First: ${chalk.cyan(`cd ${outputPath}`)} - to enter project’s directory`) - print(`Then: ${chalk.cyan(devCommand)} - to run Sanity Studio\n`) + print( + `Then: ${chalk.cyan(devCommand)} -to run ${isCoreAppTemplate ? 'your Sanity application' : 'Sanity Studio'}\n`, + ) } print(`Other helpful commands`) @@ -720,6 +730,15 @@ export default async function initSanity( return data } + if (isCoreAppTemplate) { + return { + projectId: '', + displayName: '', + isFirstProject: false, + datasetName: '', + } + } + debug('Prompting user to select or create a project') const project = await getOrCreateProject() debug(`Project with name ${project.displayName} selected`) diff --git a/packages/@sanity/cli/src/actions/init-project/processTemplate.ts b/packages/@sanity/cli/src/actions/init-project/processTemplate.ts new file mode 100644 index 00000000000..9d7705e7c06 --- /dev/null +++ b/packages/@sanity/cli/src/actions/init-project/processTemplate.ts @@ -0,0 +1,55 @@ +import traverse from '@babel/traverse' +import {parse, print} from 'recast' +import * as parser from 'recast/parsers/typescript' + +interface TemplateOptions { + template: string + variables: T + includeBooleanTransform?: boolean +} + +export function processTemplate(options: TemplateOptions): string { + const {template, variables, includeBooleanTransform = false} = options + const ast = parse(template.trimStart(), {parser}) + + traverse(ast, { + StringLiteral: { + enter({node}) { + const value = node.value + if (!value.startsWith('%') || !value.endsWith('%')) { + return + } + const variableName = value.slice(1, -1) as keyof T + if (!(variableName in variables)) { + throw new Error(`Template variable '${value}' not defined`) + } + const newValue = variables[variableName] + /* + * although there are valid non-strings in our config, + * they're not in StringLiteral nodes, so assume undefined + */ + node.value = typeof newValue === 'string' ? newValue : '' + }, + }, + ...(includeBooleanTransform && { + Identifier: { + enter(path) { + if (!path.node.name.startsWith('__BOOL__')) { + return + } + const variableName = path.node.name.replace(/^__BOOL__(.+?)__$/, '$1') as keyof T + if (!(variableName in variables)) { + throw new Error(`Template variable '${variableName.toString()}' not defined`) + } + const value = variables[variableName] + if (typeof value !== 'boolean') { + throw new Error(`Expected boolean value for '${variableName.toString()}'`) + } + path.replaceWith({type: 'BooleanLiteral', value}) + }, + }, + }), + }) + + return print(ast, {quote: 'single'}).code +} diff --git a/packages/@sanity/cli/src/actions/init-project/templates/coreApp.ts b/packages/@sanity/cli/src/actions/init-project/templates/coreApp.ts new file mode 100644 index 00000000000..4dc274e280b --- /dev/null +++ b/packages/@sanity/cli/src/actions/init-project/templates/coreApp.ts @@ -0,0 +1,31 @@ +import {type ProjectTemplate} from '../initProject' + +const coreAppTemplate: ProjectTemplate = { + dependencies: { + '@sanity/sdk': '^0.0.0-alpha', + '@sanity/sdk-react': '^0.0.0-alpha', + 'react': '^19', + 'react-dom': '^19', + }, + devDependencies: { + /* + * this will be changed to eslint-config sanity, + * eslint.config generation will be a fast follow + */ + '@sanity/eslint-config-studio': '^5.0.1', + '@types/react': '^18.0.25', + 'eslint': '^9.9.0', + 'prettier': '^3.0.2', + 'sanity': '^3', + 'typescript': '^5.1.6', + }, + appLocation: './src/App.tsx', + scripts: { + // this will eventually run a concurrently process with another in-flight utility + dev: 'sanity app dev', + build: 'sanity app build', + start: 'sanity app start', + }, +} + +export default coreAppTemplate diff --git a/packages/@sanity/cli/src/actions/init-project/templates/index.ts b/packages/@sanity/cli/src/actions/init-project/templates/index.ts index 00f90fae358..1538d1ff6a4 100644 --- a/packages/@sanity/cli/src/actions/init-project/templates/index.ts +++ b/packages/@sanity/cli/src/actions/init-project/templates/index.ts @@ -1,6 +1,7 @@ import {type ProjectTemplate} from '../initProject' import blog from './blog' import clean from './clean' +import coreAppTemplate from './coreApp' import getStartedTemplate from './getStarted' import moviedb from './moviedb' import quickstart from './quickstart' @@ -10,6 +11,7 @@ import shopifyOnline from './shopifyOnline' const templates: Record = { blog, clean, + 'core-app': coreAppTemplate, 'get-started': getStartedTemplate, moviedb, shopify, diff --git a/packages/@sanity/cli/src/types.ts b/packages/@sanity/cli/src/types.ts index 33b2c04f9f9..957e789eb37 100644 --- a/packages/@sanity/cli/src/types.ts +++ b/packages/@sanity/cli/src/types.ts @@ -24,6 +24,7 @@ export interface SanityModuleInternal { export interface PackageJson { name: string version: string + scripts?: Record description?: string author?: string @@ -344,6 +345,15 @@ export interface CliConfig { autoUpdates?: boolean studioHost?: string + + /** + * Parameter used to configure other kinds of applications. + * Signals to `sanity` commands that this is not a studio. + * @internal + */ + __experimental_coreAppConfiguration?: { + appLocation?: string + } } export type UserViteConfig = diff --git a/packages/@sanity/cli/templates/core-app/src/App.tsx b/packages/@sanity/cli/templates/core-app/src/App.tsx new file mode 100644 index 00000000000..7608e84a201 --- /dev/null +++ b/packages/@sanity/cli/templates/core-app/src/App.tsx @@ -0,0 +1,26 @@ +import {createSanityInstance} from '@sanity/sdk' +import {SanityProvider} from '@sanity/sdk-react/context' + +export function App() { + + const sanityConfig = { + auth: { + authScope: 'global' + } + /* + * Apps can access several different projects! + * Add the below configuration if you want to connect to a specific project. + */ + // projectId: 'my-project-id', + // dataset: 'my-dataset', + } + + const sanityInstance = createSanityInstance(sanityConfig) + return ( + + Hello world! + + ) +} + +export default App \ No newline at end of file diff --git a/packages/@sanity/cli/test/init.test.ts b/packages/@sanity/cli/test/init.test.ts index 08ec7960886..22b8255c339 100644 --- a/packages/@sanity/cli/test/init.test.ts +++ b/packages/@sanity/cli/test/init.test.ts @@ -3,42 +3,47 @@ import path from 'node:path' import {describe, expect} from 'vitest' +import {determineCoreAppTemplate} from '../src/actions/init-project/determineCoreAppTemplate' import templates from '../src/actions/init-project/templates' import {describeCliTest, testConcurrent} from './shared/describe' import {baseTestPath, cliProjectId, getTestRunArgs, runSanityCmdCommand} from './shared/environment' describeCliTest('CLI: `sanity init v3`', () => { - describe.each(Object.keys(templates))('for template %s', (template) => { - testConcurrent('adds autoUpdates: true to cli config', async () => { - const version = 'v3' - const testRunArgs = getTestRunArgs(version) - const outpath = `test-template-${template}-${version}` - - await runSanityCmdCommand(version, [ - 'init', - '--y', - '--project', - cliProjectId, - '--dataset', - testRunArgs.dataset, - '--template', - template, - '--output-path', - `${baseTestPath}/${outpath}`, - '--package-manager', - 'manual', - ]) - - const cliConfig = await fs.readFile( - path.join(baseTestPath, outpath, 'sanity.cli.ts'), - 'utf-8', - ) - - expect(cliConfig).toContain(`projectId: '${cliProjectId}'`) - expect(cliConfig).toContain(`dataset: '${testRunArgs.dataset}'`) - expect(cliConfig).toContain(`autoUpdates: true`) - }) - }) + // filter out non-studio apps for now, until we add things they can auto-update + describe.each(Object.keys(templates).filter((template) => !determineCoreAppTemplate(template)))( + 'for template %s', + (template) => { + testConcurrent('adds autoUpdates: true to cli config', async () => { + const version = 'v3' + const testRunArgs = getTestRunArgs(version) + const outpath = `test-template-${template}-${version}` + + await runSanityCmdCommand(version, [ + 'init', + '--y', + '--project', + cliProjectId, + '--dataset', + testRunArgs.dataset, + '--template', + template, + '--output-path', + `${baseTestPath}/${outpath}`, + '--package-manager', + 'manual', + ]) + + const cliConfig = await fs.readFile( + path.join(baseTestPath, outpath, 'sanity.cli.ts'), + 'utf-8', + ) + + expect(cliConfig).toContain(`projectId: '${cliProjectId}'`) + expect(cliConfig).toContain(`dataset: '${testRunArgs.dataset}'`) + expect(cliConfig).toContain(`autoUpdates: true`) + }) + }, + ) testConcurrent('adds autoUpdates: true to cli config for javascript projects', async () => { const version = 'v3' diff --git a/packages/sanity/src/_internal/cli/actions/build/buildAction.ts b/packages/sanity/src/_internal/cli/actions/build/buildAction.ts index 24e105335b5..d63dc19d70a 100644 --- a/packages/sanity/src/_internal/cli/actions/build/buildAction.ts +++ b/packages/sanity/src/_internal/cli/actions/build/buildAction.ts @@ -46,6 +46,7 @@ export default async function buildSanityStudio( const unattendedMode = Boolean(flags.yes || flags.y) const defaultOutputDir = path.resolve(path.join(workDir, 'dist')) const outputDir = path.resolve(args.argsWithoutOptions[0] || defaultOutputDir) + const isCoreApp = cliConfig && '__experimental_coreAppConfiguration' in cliConfig await checkStudioDependencyVersions(workDir) @@ -146,7 +147,7 @@ export default async function buildSanityStudio( spin.succeed() } - spin = output.spinner('Build Sanity Studio').start() + spin = output.spinner(`Build Sanity ${isCoreApp ? 'application' : 'Studio'}`).start() const trace = telemetry.trace(BuildTrace) trace.start() @@ -175,6 +176,11 @@ export default async function buildSanityStudio( importMap, reactCompiler: cliConfig && 'reactCompiler' in cliConfig ? cliConfig.reactCompiler : undefined, + appLocation: + cliConfig && '__experimental_coreAppConfiguration' in cliConfig + ? cliConfig.__experimental_coreAppConfiguration?.appLocation + : undefined, + isCoreApp, }) trace.log({ @@ -184,7 +190,7 @@ export default async function buildSanityStudio( }) const buildDuration = timer.end('bundleStudio') - spin.text = `Build Sanity Studio (${buildDuration.toFixed()}ms)` + spin.text = `Build Sanity ${isCoreApp ? 'application' : 'Studio'} (${buildDuration.toFixed()}ms)` spin.succeed() trace.complete() diff --git a/packages/sanity/src/_internal/cli/commands/app/appGroup.ts b/packages/sanity/src/_internal/cli/commands/app/appGroup.ts new file mode 100644 index 00000000000..21cf64189eb --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/app/appGroup.ts @@ -0,0 +1,10 @@ +import {type CliCommandGroupDefinition} from '@sanity/cli' + +const appGroup: CliCommandGroupDefinition = { + name: 'app', + signature: '[COMMAND]', + isGroupRoot: true, + description: 'Manages non-studio applications', +} + +export default appGroup diff --git a/packages/sanity/src/_internal/cli/commands/app/buildCommand.ts b/packages/sanity/src/_internal/cli/commands/app/buildCommand.ts new file mode 100644 index 00000000000..1fa56217a9f --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/app/buildCommand.ts @@ -0,0 +1,52 @@ +import { + type CliCommandArguments, + type CliCommandContext, + type CliCommandDefinition, +} from '@sanity/cli' + +import {type BuildSanityStudioCommandFlags} from '../../actions/build/buildAction' + +const helpText = ` +Options + --source-maps Enable source maps for built bundles (increases size of bundle) + --no-minify Skip minifying built JavaScript (speeds up build, increases size of bundle) + -y, --yes Unattended mode, answers "yes" to any "yes/no" prompt and otherwise uses defaults + +Examples + sanity app build + sanity app build --no-minify --source-maps +` + +const appBuildCommand: CliCommandDefinition = { + name: 'build', + group: 'app', + signature: '[OUTPUT_DIR]', + description: 'Builds the Sanity application configuration into a static bundle', + action: async ( + args: CliCommandArguments, + context: CliCommandContext, + overrides?: {basePath?: string}, + ) => { + const buildAction = await getBuildAction() + + return buildAction(args, context, overrides) + }, + helpText, +} + +async function getBuildAction() { + // NOTE: in dev-mode we want to include from `src` so we need to use `.ts` extension + // NOTE: this `if` statement is not included in the output bundle + if (__DEV__) { + // eslint-disable-next-line import/extensions,@typescript-eslint/consistent-type-imports + const mod: typeof import('../../actions/build/buildAction') = require('../../actions/build/buildAction.ts') + + return mod.default + } + + const mod = await import('../../actions/build/buildAction') + + return mod.default +} + +export default appBuildCommand diff --git a/packages/sanity/src/_internal/cli/commands/app/devCommand.ts b/packages/sanity/src/_internal/cli/commands/app/devCommand.ts new file mode 100644 index 00000000000..db0b4589894 --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/app/devCommand.ts @@ -0,0 +1,58 @@ +import { + type CliCommandArguments, + type CliCommandContext, + type CliCommandDefinition, +} from '@sanity/cli' + +import {type StartDevServerCommandFlags} from '../../actions/dev/devAction' + +const helpText = ` +Notes + Changing the hostname or port number might require a new entry to the CORS-origins allow list. + +Options + --port TCP port to start server on. [default: 3333] + --host The local network interface at which to listen. [default: "127.0.0.1"] + +Examples + sanity app dev --host=0.0.0.0 + sanity app dev --port=1942 +` + +const appDevCommand: CliCommandDefinition = { + name: 'dev', + group: 'app', + signature: '[--port ] [--host ]', + description: 'Starts a local dev server for your Sanity application with live reloading', + action: async ( + args: CliCommandArguments, + context: CliCommandContext, + ) => { + const devAction = await getDevAction() + + return devAction(args, context) + }, + helpText, +} + +export async function getDevAction(): Promise< + ( + args: CliCommandArguments, + context: CliCommandContext, + ) => Promise +> { + // NOTE: in dev-mode we want to include from `src` so we need to use `.ts` extension + // NOTE: this `if` statement is not included in the output bundle + if (__DEV__) { + // eslint-disable-next-line import/extensions,@typescript-eslint/consistent-type-imports + const mod: typeof import('../../actions/dev/devAction') = require('../../actions/dev/devAction.ts') + + return mod.default + } + + const mod = await import('../../actions/dev/devAction') + + return mod.default +} + +export default appDevCommand diff --git a/packages/sanity/src/_internal/cli/commands/app/startCommand.ts b/packages/sanity/src/_internal/cli/commands/app/startCommand.ts new file mode 100644 index 00000000000..84b27b2df56 --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/app/startCommand.ts @@ -0,0 +1,84 @@ +import { + type CliCommandArguments, + type CliCommandContext, + type CliCommandDefinition, +} from '@sanity/cli' + +import {type StartPreviewServerCommandFlags} from '../../actions/preview/previewAction' +import {isInteractive} from '../../util/isInteractive' +import {getDevAction} from '../dev/devCommand' + +const helpText = ` +Notes + Changing the hostname or port number might require a new CORS-entry to be added. + +Options + --port TCP port to start server on. [default: 3333] + --host The local network interface at which to listen. [default: "127.0.0.1"] + +Examples + sanity app start --host=0.0.0.0 + sanity app start --port=1942 + sanity app start some/build-output-dir +` + +const appStartCommand: CliCommandDefinition = { + name: 'start', + group: 'app', + signature: '[BUILD_OUTPUT_DIR] [--port ] [--host ]', + description: 'Previews a built Sanity application', + action: async ( + args: CliCommandArguments, + context: CliCommandContext, + ) => { + const {output, chalk, prompt} = context + const previewAction = await getPreviewAction() + + const error = (msg: string) => output.warn(chalk.red.bgBlack(msg)) + + try { + await previewAction(args, context) + } catch (err) { + if (err.name !== 'BUILD_NOT_FOUND') { + throw err + } + + error(err.message) + error('\n') + + const shouldRunDevServer = + isInteractive && + (await prompt.single({ + message: 'Do you want to start a development server instead?', + type: 'confirm', + })) + + if (shouldRunDevServer) { + const devAction = await getDevAction() + await devAction(args, context) + } else { + // Indicate that this isn't an expected exit + // eslint-disable-next-line no-process-exit + process.exit(1) + } + } + }, + helpText, +} + +async function getPreviewAction() { + // NOTE: in dev-mode we want to include from `src` so we need to use `.ts` extension + // NOTE: this `if` statement is not included in the output bundle + if (__DEV__) { + // eslint-disable-next-line import/extensions,@typescript-eslint/consistent-type-imports + const mod: typeof import('../../actions/preview/previewAction') = require('../../actions/preview/previewAction.ts') + + return mod.default + } + + const mod = await import('../../actions/preview/previewAction') + + return mod.default +} + +export default appStartCommand diff --git a/packages/sanity/src/_internal/cli/commands/index.ts b/packages/sanity/src/_internal/cli/commands/index.ts index 7de5e343395..68f4fd4af29 100644 --- a/packages/sanity/src/_internal/cli/commands/index.ts +++ b/packages/sanity/src/_internal/cli/commands/index.ts @@ -1,5 +1,9 @@ import {type CliCommandDefinition, type CliCommandGroupDefinition} from '@sanity/cli' +import appGroup from './app/appGroup' +import appBuildCommand from './app/buildCommand' +import appDevCommand from './app/devCommand' +import appStartCommand from './app/startCommand' import backupGroup from './backup/backupGroup' import disableBackupCommand from './backup/disableBackupCommand' import downloadBackupCommand from './backup/downloadBackupCommand' @@ -55,6 +59,10 @@ import listUsersCommand from './users/listUsersCommand' import usersGroup from './users/usersGroup' const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [ + appGroup, + appDevCommand, + appBuildCommand, + appStartCommand, buildCommand, datasetGroup, deployCommand, diff --git a/packages/sanity/src/_internal/cli/server/buildStaticFiles.ts b/packages/sanity/src/_internal/cli/server/buildStaticFiles.ts index 1b500c2327e..b109f6e95ac 100644 --- a/packages/sanity/src/_internal/cli/server/buildStaticFiles.ts +++ b/packages/sanity/src/_internal/cli/server/buildStaticFiles.ts @@ -34,6 +34,8 @@ export interface StaticBuildOptions { vite?: UserViteConfig reactCompiler: ReactCompilerConfig | undefined + appLocation?: string + isCoreApp?: boolean } export async function buildStaticFiles( @@ -48,10 +50,19 @@ export async function buildStaticFiles( vite: extendViteConfig, importMap, reactCompiler, + appLocation, + isCoreApp, } = options debug('Writing Sanity runtime files') - await writeSanityRuntime({cwd, reactStrictMode: false, watch: false, basePath}) + await writeSanityRuntime({ + cwd, + reactStrictMode: false, + watch: false, + basePath, + appLocation, + isCoreApp, + }) debug('Resolving vite config') const mode = 'production' @@ -64,6 +75,7 @@ export async function buildStaticFiles( mode, importMap, reactCompiler, + isCoreApp, }) // Extend Vite configuration with user-provided config diff --git a/packages/sanity/src/_internal/cli/server/devServer.ts b/packages/sanity/src/_internal/cli/server/devServer.ts index 8d92e89b14b..1c8a7e9160d 100644 --- a/packages/sanity/src/_internal/cli/server/devServer.ts +++ b/packages/sanity/src/_internal/cli/server/devServer.ts @@ -17,6 +17,8 @@ export interface DevServerOptions { reactStrictMode: boolean reactCompiler: ReactCompilerConfig | undefined vite?: UserViteConfig + appLocation?: string + isCoreApp?: boolean } export interface DevServer { @@ -32,20 +34,24 @@ export async function startDevServer(options: DevServerOptions): Promise} reactCompiler: ReactCompilerConfig | undefined + isCoreApp?: boolean } /** @@ -72,6 +73,7 @@ export async function getViteConfig(options: ViteOptions): Promise basePath: rawBasePath = '/', importMap, reactCompiler, + isCoreApp, } = options const monorepo = await loadSanityMonorepo(cwd) @@ -110,9 +112,9 @@ export async function getViteConfig(options: ViteOptions): Promise ), sanityFaviconsPlugin({defaultFaviconsPath, customFaviconsPath, staticUrlPath: staticPath}), sanityRuntimeRewritePlugin(), - sanityBuildEntries({basePath, cwd, monorepo, importMap}), + sanityBuildEntries({basePath, cwd, monorepo, importMap, isCoreApp}), ], - envPrefix: 'SANITY_STUDIO_', + envPrefix: isCoreApp ? 'VITE_' : 'SANITY_STUDIO_', logLevel: mode === 'production' ? 'silent' : 'info', resolve: { alias: monorepo?.path diff --git a/packages/sanity/src/_internal/cli/server/previewServer.ts b/packages/sanity/src/_internal/cli/server/previewServer.ts index bc5b23bf554..82d61844e39 100644 --- a/packages/sanity/src/_internal/cli/server/previewServer.ts +++ b/packages/sanity/src/_internal/cli/server/previewServer.ts @@ -24,10 +24,11 @@ export interface PreviewServerOptions { httpHost?: string vite?: UserViteConfig + isCoreApp?: boolean } export async function startPreviewServer(options: PreviewServerOptions): Promise { - const {httpPort, httpHost, root, vite: extendViteConfig} = options + const {httpPort, httpHost, root, vite: extendViteConfig, isCoreApp} = options const startTime = Date.now() const indexPath = path.join(root, 'index.html') @@ -41,7 +42,7 @@ export async function startPreviewServer(options: PreviewServerOptions): Promise } const error = new Error( - `Could not find a production build in the '${root}' directory.\nTry building your studio app with 'sanity build' before starting the preview server.`, + `Could not find a production build in the '${root}' directory.\nTry building your ${isCoreApp ? '' : 'studio '}app with 'sanity build' before starting the preview server.`, ) error.name = 'BUILD_NOT_FOUND' throw error @@ -90,7 +91,7 @@ export async function startPreviewServer(options: PreviewServerOptions): Promise const startupDuration = Date.now() - startTime info( - `Sanity Studio ` + + `Sanity ${isCoreApp ? 'application' : 'Studio'} ` + `using ${chalk.cyan(`vite@${require('vite/package.json').version}`)} ` + `ready in ${chalk.cyan(`${Math.ceil(startupDuration)}ms`)} ` + `and running at ${chalk.cyan(url)} (production preview mode)`, diff --git a/packages/sanity/src/_internal/cli/server/renderDocument.tsx b/packages/sanity/src/_internal/cli/server/renderDocument.tsx index 0e1be1c83f9..edafdb9f6b7 100644 --- a/packages/sanity/src/_internal/cli/server/renderDocument.tsx +++ b/packages/sanity/src/_internal/cli/server/renderDocument.tsx @@ -48,12 +48,20 @@ interface RenderDocumentOptions { importMap?: { imports?: Record } + isCoreApp?: boolean } export function renderDocument(options: RenderDocumentOptions): Promise { return new Promise((resolve, reject) => { if (!useThreads) { - resolve(getDocumentHtml(options.studioRootPath, options.props, options.importMap)) + resolve( + getDocumentHtml( + options.studioRootPath, + options.props, + options.importMap, + options.isCoreApp, + ), + ) return } @@ -150,7 +158,8 @@ function renderDocumentFromWorkerData() { throw new Error('Must be used as a Worker with a valid options object in worker data') } - const {monorepo, studioRootPath, props, importMap}: RenderDocumentOptions = workerData || {} + const {monorepo, studioRootPath, props, importMap, isCoreApp}: RenderDocumentOptions = + workerData || {} if (workerData?.dev) { // Define `__DEV__` in the worker thread as well @@ -200,7 +209,7 @@ function renderDocumentFromWorkerData() { loader: 'jsx', }) - const html = getDocumentHtml(studioRootPath, props, importMap) + const html = getDocumentHtml(studioRootPath, props, importMap, isCoreApp) parentPort.postMessage({type: 'result', html}) @@ -213,8 +222,9 @@ function getDocumentHtml( studioRootPath: string, props?: DocumentProps, importMap?: {imports?: Record}, + isCoreApp?: boolean, ): string { - const Document = getDocumentComponent(studioRootPath) + const Document = getDocumentComponent(studioRootPath, isCoreApp) // NOTE: Validate the list of CSS paths so implementers of `_document.tsx` don't have to // - If the path is not a full URL, check if it starts with `/` @@ -270,18 +280,25 @@ export function addTimestampedImportMapScriptToHtml( return root.outerHTML } -function getDocumentComponent(studioRootPath: string) { +function getDocumentComponent(studioRootPath: string, isCoreApp?: boolean) { debug('Loading default document component from `sanity` module') + + const {BasicDocument} = __DEV__ + ? require('../../../core/components/BasicDocument') + : require('sanity') + const {DefaultDocument} = __DEV__ ? require('../../../core/components/DefaultDocument') : require('sanity') + const Document = isCoreApp ? BasicDocument : DefaultDocument + debug('Attempting to load user-defined document component from %s', studioRootPath) const userDefined = tryLoadDocumentComponent(studioRootPath) if (!userDefined) { debug('Using default document component') - return DefaultDocument + return Document } debug('Found user defined document component at %s', userDefined.path) diff --git a/packages/sanity/src/_internal/cli/server/runtime.ts b/packages/sanity/src/_internal/cli/server/runtime.ts index 40b491ede45..8c3bcf508d9 100644 --- a/packages/sanity/src/_internal/cli/server/runtime.ts +++ b/packages/sanity/src/_internal/cli/server/runtime.ts @@ -20,6 +20,8 @@ export interface RuntimeOptions { reactStrictMode: boolean watch: boolean basePath?: string + appLocation?: string + isCoreApp?: boolean } /** @@ -34,6 +36,8 @@ export async function writeSanityRuntime({ reactStrictMode, watch, basePath, + appLocation, + isCoreApp, }: RuntimeOptions): Promise { debug('Resolving Sanity monorepo information') const monorepo = await loadSanityMonorepo(cwd) @@ -52,6 +56,7 @@ export async function writeSanityRuntime({ entryPath: `/${path.relative(cwd, path.join(runtimeDir, 'app.js'))}`, basePath: basePath || '/', }, + isCoreApp, }), ) @@ -68,13 +73,19 @@ export async function writeSanityRuntime({ await renderAndWriteDocument() debug('Writing app.js to runtime directory') - const studioConfigPath = await getSanityStudioConfigPath(cwd) - const relativeConfigLocation = studioConfigPath - ? path.relative(runtimeDir, studioConfigPath) - : null + let relativeConfigLocation: string | null = null + if (!isCoreApp) { + const studioConfigPath = await getSanityStudioConfigPath(cwd) + relativeConfigLocation = studioConfigPath ? path.relative(runtimeDir, studioConfigPath) : null + } - await fs.writeFile( - path.join(runtimeDir, 'app.js'), - getEntryModule({reactStrictMode, relativeConfigLocation, basePath}), - ) + const relativeAppLocation = cwd ? path.resolve(cwd, appLocation || './src/App') : appLocation + const appJsContent = getEntryModule({ + reactStrictMode, + relativeConfigLocation, + basePath, + appLocation: relativeAppLocation, + isCoreApp, + }) + await fs.writeFile(path.join(runtimeDir, 'app.js'), appJsContent) } diff --git a/packages/sanity/src/_internal/cli/server/vite/plugin-sanity-build-entries.ts b/packages/sanity/src/_internal/cli/server/vite/plugin-sanity-build-entries.ts index 8101f30c02c..42b6fc2b1b0 100644 --- a/packages/sanity/src/_internal/cli/server/vite/plugin-sanity-build-entries.ts +++ b/packages/sanity/src/_internal/cli/server/vite/plugin-sanity-build-entries.ts @@ -19,6 +19,7 @@ interface ViteRenderedChunk { code: string imports: string[] viteMetadata: ChunkMetadata + isEntry: boolean } const entryChunkId = '.sanity/runtime/app.js' @@ -28,8 +29,9 @@ export function sanityBuildEntries(options: { monorepo: SanityMonorepo | undefined basePath: string importMap?: {imports?: Record} + isCoreApp?: boolean }): Plugin { - const {cwd, monorepo, basePath, importMap} = options + const {cwd, monorepo, basePath, importMap, isCoreApp} = options return { name: 'sanity/server/build-entries', @@ -93,6 +95,7 @@ export function sanityBuildEntries(options: { entryPath, css, }, + isCoreApp, }), }) }, diff --git a/packages/sanity/src/_internal/cli/util/checkRequiredDependencies.ts b/packages/sanity/src/_internal/cli/util/checkRequiredDependencies.ts index 258c6fa8a22..5781a1b0a0e 100644 --- a/packages/sanity/src/_internal/cli/util/checkRequiredDependencies.ts +++ b/packages/sanity/src/_internal/cli/util/checkRequiredDependencies.ts @@ -31,6 +31,13 @@ interface CheckResult { * Additionally, returns the version of the 'sanity' dependency from the package.json. */ export async function checkRequiredDependencies(context: CliCommandContext): Promise { + // currently there's no check needed for core apps, + // but this should be removed once they are more mature + const isCoreApp = context.cliConfig && '__experimental_coreAppConfiguration' in context.cliConfig + if (isCoreApp) { + return {didInstall: false, installedSanityVersion: ''} + } + const {workDir: studioPath, output} = context const [studioPackageManifest, installedStyledComponentsVersion, installedSanityVersion] = await Promise.all([ diff --git a/packages/sanity/src/_internal/cli/util/servers.ts b/packages/sanity/src/_internal/cli/util/servers.ts index b45933d34ed..bee79446864 100644 --- a/packages/sanity/src/_internal/cli/util/servers.ts +++ b/packages/sanity/src/_internal/cli/util/servers.ts @@ -48,6 +48,8 @@ export function getSharedServerConfig({ httpHost: string basePath: string vite: CliConfig['vite'] + appLocation?: string + isCoreApp?: boolean } { // Order of preference: CLI flags, environment variables, user build config, default config const env = process.env // eslint-disable-line no-process-env @@ -64,12 +66,17 @@ export function getSharedServerConfig({ env.SANITY_STUDIO_BASEPATH ?? (cliConfig?.project?.basePath || '/'), ) + const isCoreApp = cliConfig && '__experimental_coreAppConfiguration' in cliConfig + const appLocation = cliConfig?.__experimental_coreAppConfiguration?.appLocation + return { cwd: workDir, httpPort, httpHost, basePath, vite: cliConfig?.vite, + appLocation, + isCoreApp, } } diff --git a/packages/sanity/src/core/components/BasicDocument.tsx b/packages/sanity/src/core/components/BasicDocument.tsx new file mode 100644 index 00000000000..6f62ddadb99 --- /dev/null +++ b/packages/sanity/src/core/components/BasicDocument.tsx @@ -0,0 +1,49 @@ +/* eslint-disable i18next/no-literal-string -- title is literal for now */ +import {Favicons} from './Favicons' +import {GlobalErrorHandler} from './globalErrorHandler' +import {NoJavascript} from './NoJavascript' + +/** + * @hidden + * @beta */ +export interface BasicDocumentProps { + entryPath: string + css?: string[] + // Currently unused, but kept for potential future use + // eslint-disable-next-line react/no-unused-prop-types + basePath?: string +} + +const EMPTY_ARRAY: never[] = [] + +/** + * This is the equivalent of DefaultDocument for non-studio apps. + * @hidden + * @beta */ +export function BasicDocument(props: BasicDocumentProps): React.JSX.Element { + const {entryPath, css = EMPTY_ARRAY} = props + + return ( + + + + + + + + + Sanity CORE App + + + {css.map((href) => ( + + ))} + + +
+