Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): add non-studio app template #8394

Merged
merged 5 commits into from
Feb 5, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/@sanity/cli/.depcheckrc.json
Original file line number Diff line number Diff line change
@@ -13,6 +13,8 @@
"@portabletext/types",
"slug",
"@sanity/asset-utils",
"@sanity/sdk",
"@sanity/sdk-react",
"styled-components",
"sanity-plugin-hotspot-array",
"react-icons",
Original file line number Diff line number Diff line change
@@ -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}`)
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {processTemplate} from './processTemplate'

const defaultCoreAppTemplate = `
import {defineCliConfig} from 'sanity/cli'

export default defineCliConfig({
__experimental_coreAppConfiguration: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Confirm this is the key we want to use

appLocation: '%appLocation%'
},
})
`

export interface GenerateCliConfigOptions {
organizationId?: string
appLocation: string
}

export function createCoreAppCliConfig(options: GenerateCliConfigOptions): string {
return processTemplate({
template: defaultCoreAppTemplate,
variables: options,
})
}
Original file line number Diff line number Diff line change
@@ -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',
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 21 additions & 2 deletions packages/@sanity/cli/src/actions/init-project/initProject.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

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`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import traverse from '@babel/traverse'
import {parse, print} from 'recast'
import * as parser from 'recast/parsers/typescript'

interface TemplateOptions<T> {
template: string
variables: T
includeBooleanTransform?: boolean
}

export function processTemplate<T extends object>(options: TemplateOptions<T>): 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
}
Loading

Unchanged files with check annotations Beta

)
const debounceSelectionChange = useMemo(
() => debounce(handleSelectionChange, 200),

Check warning on line 262 in packages/sanity/src/core/comments/plugin/input/components/CommentsPortableTextInput.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
[handleSelectionChange],
)
const currentSelectionIsOverlapping = useMemo(() => {
if (!currentSelection || addedCommentsDecorations.length === 0) return false
return addedCommentsDecorations.some((d) => {

Check warning on line 441 in packages/sanity/src/core/comments/plugin/input/components/CommentsPortableTextInput.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
if (!editorRef.current) return false
const testA = PortableTextEditor.isSelectionsOverlapping(
}, [handleBuildRangeDecorations, textComments])
const showFloatingButton = Boolean(
currentSelection && canSubmit && selectionReferenceElement && !mouseDownRef.current,

Check warning on line 528 in packages/sanity/src/core/comments/plugin/input/components/CommentsPortableTextInput.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
)
const showFloatingInput = Boolean(nextCommentSelection && popoverAuthoringReferenceElement)
</BoundaryElementProvider>
<Stack ref={setRootElement} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
{props.renderDefault({

Check warning on line 561 in packages/sanity/src/core/comments/plugin/input/components/CommentsPortableTextInput.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
...props,
onEditorChange,
editorRef,
const disabled = getItemDisabled?.(index) ?? false
const selected = getItemSelected?.(index) ?? false
if (!disabled) {
i += 1

Check warning on line 185 in packages/sanity/src/core/components/commandList/CommandList.tsx

GitHub Actions / lint

Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead
}
acc[index] = {
activeIndex: disabled ? null : i,
const rangeDecorations = useMemo((): RangeDecoration[] => {
const result = [...(rangeDecorationsProp || []), ...presenceCursorDecorations]
const reconciled = immutableReconcile(previousRangeDecorations.current, result)

Check warning on line 296 in packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
previousRangeDecorations.current = reconciled

Check warning on line 297 in packages/sanity/src/core/form/inputs/PortableText/PortableTextInput.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
return reconciled
}, [presenceCursorDecorations, rangeDecorationsProp])
[validation],
)
const reconciled = immutableReconcile(prev.current, validation)

Check warning on line 36 in packages/sanity/src/core/form/inputs/PortableText/hooks/useMemberValidation.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
prev.current = reconciled

Check warning on line 37 in packages/sanity/src/core/form/inputs/PortableText/hooks/useMemberValidation.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
return useMemo(() => {
return {
}
}
const items: PortableTextMemberItem[] = result.map((item) => {

Check warning on line 121 in packages/sanity/src/core/form/inputs/PortableText/hooks/usePortableTextMembers.tsx

GitHub Actions / lint

Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
const key = pathToString(item.node.path)
const existingItem = portableTextMemberItemsRef.current.find((refItem) => refItem.key === key)
const isObject = item.kind !== 'textBlock'
export const BrandLogo = () => (

Check warning on line 1 in examples/movies-studio/components/BrandLogo.tsx

GitHub Actions / lint

Missing return type on function.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 530">
<g>
<path
return str.replace(/^drafts\./, '')
}
export function resolveProductionUrl(document, rev) {

Check warning on line 7 in examples/movies-studio/resolveProductionUrl.ts

GitHub Actions / lint

Missing return type on function.

Check warning on line 7 in examples/movies-studio/resolveProductionUrl.ts

GitHub Actions / lint

Argument 'document' should be typed.

Check warning on line 7 in examples/movies-studio/resolveProductionUrl.ts

GitHub Actions / lint

Argument 'rev' should be typed.
const id = stripDraftId(document._id)
if (rev) {
],
document: {
// @todo

Check warning on line 34 in examples/movies-studio/sanity.config.ts

GitHub Actions / lint

Unexpected 'todo' comment: '@todo'.
//productionUrl: resolveProductionUrl,
},
})
* @returns The same string as the input
* @public
*/
export function groq(strings: TemplateStringsArray, ...keys: any[]): string {

Check warning on line 11 in packages/groq/src/groq.ts

GitHub Actions / lint

Unexpected any. Specify a different type.
const lastIndex = strings.length - 1
return (
strings.slice(0, lastIndex).reduce((acc, str, i) => {
export function diffNumber<A>(
fromInput: NumberInput<A>,
toInput: NumberInput<A>,
options: DiffOptions,

Check warning on line 12 in packages/@sanity/diff/src/calculate/diffSimple.ts

GitHub Actions / lint

'options' is defined but never used.

Check warning on line 12 in packages/@sanity/diff/src/calculate/diffSimple.ts

GitHub Actions / lint

'options' is defined but never used.
): NumberDiff<A> {
const fromValue = fromInput.value
const toValue = toInput.value
export function diffBoolean<A>(
fromInput: BooleanInput<A>,
toInput: BooleanInput<A>,
options: DiffOptions,

Check warning on line 40 in packages/@sanity/diff/src/calculate/diffSimple.ts

GitHub Actions / lint

'options' is defined but never used.

Check warning on line 40 in packages/@sanity/diff/src/calculate/diffSimple.ts

GitHub Actions / lint

'options' is defined but never used.
): BooleanDiff<A> {
const fromValue = fromInput.value
const toValue = toInput.value