Skip to content

Commit

Permalink
fix(vite): fix vite project import (#6420)
Browse files Browse the repository at this point in the history
**Problem:**
Trying to import a [vanilla Vite
project](https://github.com/liady/utopia-vite-project) fails today, with
multiple issues, and results in a broken editor.

**Fix:**
Create a `FrameworkHooks` interface that the Vite specific code can
implement, to hook into storyboard creation as well as module resolution
([`frameworks-hooks-vite.ts`](https://github.com/concrete-utopia/utopia/pull/6420/files#diff-b640da014227f136acea49663e81e9106c84da8f6e2c305ed21b51615c8048fe)).

<img width="1083" alt="image"
src="https://github.com/user-attachments/assets/dee615a5-b218-4c94-a349-386c7c9ccc24">

### Creation of `storyboard.js`
([`storyboard-utils.ts`](https://github.com/concrete-utopia/utopia/pull/6420/files#diff-a071a27ce64f0de07b4bcf1da78fb0ba2113311d20653a726272561f0314902b)):
* Detect main html file (`index.html`) if present, extract from it root
element class name, root element id (`root`) and main script module
(`main.js`)
* From main script module, if present, extract main app candidate (`<App
/>`) and relevant css imports (`./index.css`)
* Have the `storyboard.js` file import correctly the main app component,
as well as relevant css imports. Assign id and class name to the
`Scene`, if found.

### Module resolution fallbacks
([`package-manager.ts`](https://github.com/concrete-utopia/utopia/pull/6420/files#diff-a48ec59247957af2197227e3d2c2c2e5156fd31589c4981df5828ca15ac9db5b))
* Have a framework-specific fallback for cases like `/vite.svg`, that
can lookup root files in the public folder as well.

### Fixes in other PRs:
* #6445 - Prevent css root dimensions (`*-width`, `*-height`) from
affecting the canvas itself.

**Manual Tests:**
I hereby swear that:

- [x] I opened a hydrogen project and it loaded
- [x] I could navigate to various routes in Preview mode
  • Loading branch information
liady authored Oct 7, 2024
1 parent a20a528 commit 562aec5
Show file tree
Hide file tree
Showing 10 changed files with 380 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,18 @@ export function isResolveSuccess<T>(
return resolveResult.type === 'RESOLVE_SUCCESS'
}

export function isResolveNotPresent<T>(
resolveResult: ResolveResult<T>,
): resolveResult is ResolveNotPresent {
return resolveResult.type === 'RESOLVE_NOT_PRESENT'
}

interface FoundFile {
path: string
file: ESCodeFile | ESRemoteDependencyPlaceholder
}

type FileLookupResult = ResolveResult<FoundFile>
export type FileLookupResult = ResolveResult<FoundFile>

function fileLookupResult(
path: string,
Expand Down
15 changes: 14 additions & 1 deletion editor/src/core/es-modules/package-manager/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import type { NodeModules, ESCodeFile } from '../../shared/project-file-types'
import { isEsCodeFile, isEsRemoteDependencyPlaceholder } from '../../shared/project-file-types'
import type { RequireFn, TypeDefinitions } from '../../shared/npm-dependency-types'
import {
isResolveNotPresent,
isResolveSuccess,
isResolveSuccessIgnoreModule,
resolveModule,
resolveModulePath,
} from './module-resolution'
import { evaluator } from '../evaluator/evaluator'
import { fetchMissingFileDependency } from './fetch-packages'
Expand All @@ -21,6 +21,8 @@ import { string } from 'prop-types'
import { Either } from '../../shared/either'
import type { CurriedUtopiaRequireFn } from '../../../components/custom-code/code-file'
import type { BuiltInDependencies } from './built-in-dependencies-list'
import type { FrameworkHooks } from '../../frameworks/framework-hooks'
import { getFrameworkHooks } from '../../frameworks/framework-hooks'

export interface FileEvaluationCache {
exports: any
Expand Down Expand Up @@ -96,6 +98,7 @@ export function getRequireFn(
builtInDependencies: BuiltInDependencies,
injectedEvaluator = evaluator,
): RequireFn {
const frameworkHooks: FrameworkHooks = getFrameworkHooks(projectContents)
return function require(importOrigin, toImport): unknown {
const builtInDependency = resolveBuiltInDependency(builtInDependencies, toImport)
if (builtInDependency != null) {
Expand Down Expand Up @@ -181,6 +184,16 @@ export function getRequireFn(

throw createResolvingRemoteDependencyError(toImport)
}
} else if (isResolveNotPresent(resolveResult)) {
const frameworkLookupPath = frameworkHooks.onResolveModuleNotPresent(
projectContents,
nodeModules,
importOrigin,
toImport,
)
if (frameworkLookupPath != null) {
return require(importOrigin, frameworkLookupPath)
}
}
throw createDependencyNotFoundError(importOrigin, toImport)
}
Expand Down
16 changes: 16 additions & 0 deletions editor/src/core/frameworks/framework-hooks-default.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { CreationDataFromProject } from '../model/storyboard-utils'
import type { FrameworkHooks } from './framework-hooks'

export class DefaultFrameworkHooks implements FrameworkHooks {
detect(): boolean {
return true
}

onProjectImport(): CreationDataFromProject | null {
return null
}

onResolveModuleNotPresent(): string | null {
return null
}
}
173 changes: 173 additions & 0 deletions editor/src/core/frameworks/framework-hooks-vite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { NodeModules } from '../shared/project-file-types'
import type { FrameworkHooks } from './framework-hooks'
import { getProjectFileByFilePath, isProjectContentFile } from '../../components/assets'
import type { Imports } from '../shared/project-file-types'
import { forEachParseSuccess, isEmptyImportDetails, isTextFile } from '../shared/project-file-types'
import type { ProjectContentTreeRoot } from 'utopia-shared/src/types'
import { getFilePathMappings } from '../model/project-file-utils'
import type { ComponentToImport, CreationDataFromProject } from '../model/storyboard-utils'
import { namedComponentToImport, PossiblyMainComponentNames } from '../model/storyboard-utils'
import { mergeImports } from '../workers/common/project-file-utils'
import { absolutePathFromRelativePath } from '../../utils/path-utils'
import type { FileLookupResult } from '../es-modules/package-manager/module-resolution'
import { isResolveSuccess, resolveModule } from '../es-modules/package-manager/module-resolution'
import { getMainScriptElement, getRootElement, parseHtml } from '../shared/dom-utils'

export class ViteFrameworkHooks implements FrameworkHooks {
detect(projectContents: ProjectContentTreeRoot): boolean {
return hasViteConfig(projectContents)
}

onProjectImport(projectContents: ProjectContentTreeRoot): CreationDataFromProject | null {
return getCreationDataFromProject(projectContents)
}

onResolveModuleNotPresent(
projectContents: ProjectContentTreeRoot,
nodeModules: NodeModules,
importOrigin: string,
toImport: string,
): string | null {
if (isInRootPath(toImport)) {
const publicResolveResult = resolveModuleFromPublicDir(
projectContents,
nodeModules,
importOrigin,
toImport,
)
if (isResolveSuccess(publicResolveResult)) {
return publicResolveResult.success.path
}
}
return null
}
}

const configFileNames = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']

function hasViteConfig(projectContents: ProjectContentTreeRoot): boolean {
return configFileNames.some((fileName) => projectContents[fileName] != null)
}

export function getCreationDataFromProject(
projectContents: ProjectContentTreeRoot,
): CreationDataFromProject {
const componentsToImport: ComponentToImport[] = []
let extraImports: Imports = {}
const creationDataFromHtml = extractCreationDataFromMainHtmlFile(projectContents)
if (creationDataFromHtml.maybeMainScriptFilePath != null) {
const mainScriptFile = getProjectFileByFilePath(
projectContents,
creationDataFromHtml.maybeMainScriptFilePath,
)

if (mainScriptFile != null && isTextFile(mainScriptFile)) {
forEachParseSuccess((success) => {
// check for import candidates
function toAbsolutePath(importSource: string): string {
if (creationDataFromHtml.maybeMainScriptFilePath != null) {
return absolutePathFromRelativePath(
creationDataFromHtml.maybeMainScriptFilePath,
false,
importSource,
)
}
return importSource
}
Object.entries(success.imports).forEach(([importSource, importDetails]) => {
// "import App from '...'"
if (
importDetails.importedWithName != null &&
PossiblyMainComponentNames.includes(importDetails.importedWithName)
) {
componentsToImport.push(
namedComponentToImport(
toAbsolutePath(importSource),
true,
'default',
importDetails.importedWithName,
),
)
}
// "import { App } from '...'"
importDetails.importedFromWithin.forEach((importAlias) => {
if (PossiblyMainComponentNames.includes(importAlias.alias)) {
componentsToImport.push(
namedComponentToImport(
toAbsolutePath(importSource),
true,
importAlias.name,
importAlias.alias,
),
)
}
})
// "import '...'"
if (isEmptyImportDetails(importDetails)) {
extraImports = mergeImports(
creationDataFromHtml.maybeMainScriptFilePath ?? '',
getFilePathMappings(projectContents),
extraImports,
{
[toAbsolutePath(importSource)]: importDetails,
},
).imports
}
})
}, mainScriptFile.fileContents.parsed)
}
}
return {
maybeRootElementId: creationDataFromHtml.maybeRootElementId,
maybeRootElementClass: creationDataFromHtml.maybeRootElementClass,
componentsToImport: componentsToImport,
extraImports: extraImports,
}
}

// TODO: read from vite config
const MAIN_HTML_FILE_NAME = 'index.html'

function extractCreationDataFromMainHtmlFile(projectContents: ProjectContentTreeRoot): {
maybeRootElementId?: string
maybeRootElementClass?: string
maybeMainScriptFilePath?: string
} {
const indexHtmlFile = projectContents[MAIN_HTML_FILE_NAME]
if (
indexHtmlFile != null &&
isProjectContentFile(indexHtmlFile) &&
isTextFile(indexHtmlFile.content)
) {
// try to parse the html file
const doc = parseHtml(indexHtmlFile.content.fileContents.code)
if (doc != null) {
const rootElement = getRootElement(doc)
const mainScriptElement = getMainScriptElement(doc)
return {
maybeRootElementId: rootElement?.id,
maybeRootElementClass: rootElement?.className,
maybeMainScriptFilePath: mainScriptElement?.getAttribute('src') ?? undefined,
}
}
}
return {}
}

// TODO: read from vite config
const PUBLIC_FOLDER_PATH = 'public'

function isInRootPath(toImport: string, publicDir: string = PUBLIC_FOLDER_PATH): boolean {
return toImport.startsWith('/') && !toImport.startsWith(`/${publicDir}/`)
}

function resolveModuleFromPublicDir(
projectContents: ProjectContentTreeRoot,
nodeModules: NodeModules,
importOrigin: string,
toImport: string,
publicDir: string = PUBLIC_FOLDER_PATH,
): FileLookupResult {
const publicPath = `/${publicDir}/${toImport}`.replace('//', '/').replace('/./', '/')
return resolveModule(projectContents, nodeModules, importOrigin, publicPath)
}
25 changes: 25 additions & 0 deletions editor/src/core/frameworks/framework-hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ProjectContentTreeRoot } from 'utopia-shared/src/types'
import type { CreationDataFromProject } from '../model/storyboard-utils'
import type { NodeModules } from '../shared/project-file-types'
import { ViteFrameworkHooks } from './framework-hooks-vite'
import { DefaultFrameworkHooks } from './framework-hooks-default'

export interface FrameworkHooks {
detect: (projectContents: ProjectContentTreeRoot) => boolean
onProjectImport: (projectContents: ProjectContentTreeRoot) => CreationDataFromProject | null
onResolveModuleNotPresent: (
projectContents: ProjectContentTreeRoot,
nodeModules: NodeModules,
importOrigin: string,
toImport: string,
) => string | null
}

const defaultFrameworkHooks = new DefaultFrameworkHooks()
const frameworkHooks: Array<FrameworkHooks> = [new ViteFrameworkHooks(), defaultFrameworkHooks]

export function getFrameworkHooks(projectContents: ProjectContentTreeRoot): FrameworkHooks {
return (
frameworkHooks.find((framework) => framework.detect(projectContents)) ?? defaultFrameworkHooks
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Array [
Object {
"arbitraryJSBlock": null,
"blockOrExpression": "block",
"declarationSyntax": "var",
"declarationSyntax": "const",
"functionWrapping": Array [],
"isFunction": false,
"name": "storyboard",
Expand Down
6 changes: 5 additions & 1 deletion editor/src/core/model/scene-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
JSXElementChild,
TopLevelElement,
JSXAttributes,
JSExpression,
} from '../shared/element-template'
import {
utopiaJSXComponent,
Expand Down Expand Up @@ -34,6 +35,7 @@ import { getProjectFileByFilePath } from '../../components/assets'
import { getUtopiaJSXComponentsFromSuccess } from './project-file-utils'
import { generateConsistentUID, getUtopiaID } from '../shared/uid-utils'
import { hashObject } from '../shared/hash'
import type { MapLike } from 'typescript'

export const PathForSceneComponent = PP.create('component')
export const PathForSceneDataUid = PP.create('data-uid')
Expand Down Expand Up @@ -114,7 +116,7 @@ export function convertScenesToUtopiaCanvasComponent(
return utopiaJSXComponent(
BakedInStoryboardVariableName,
false,
'var',
'const',
'block',
[],
null,
Expand All @@ -135,6 +137,7 @@ export function createSceneFromComponent(
filePath: string,
componentImportedAs: string,
uid: string,
additionalAttributes: MapLike<JSExpression> = {},
): JSXElement {
const sceneProps = jsxAttributesFromMap({
[UTOPIA_UID_KEY]: jsExpressionValue(uid, emptyComments),
Expand All @@ -148,6 +151,7 @@ export function createSceneFromComponent(
},
emptyComments,
),
...additionalAttributes,
})
const hash = hashObject({
fileName: filePath,
Expand Down
Loading

0 comments on commit 562aec5

Please sign in to comment.