Skip to content
Merged
Show file tree
Hide file tree
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
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,13 @@ export class NuxtConfigModuleCodemod implements Codemod<t.File, CodemodOptions>
return false;
}

private static findConfig(ast: t.File): t.ObjectExpression | null {
/**
* Locates the object literal holding the Nuxt configuration.
*
* @param ast The parsed configuration file.
* @returns The configuration object, or null if it cannot be statically resolved.
*/
public static findConfig(ast: t.File): t.ObjectExpression | null {
const defineName = NuxtConfigModuleCodemod.resolveDefineName(ast);

let configObject: t.ObjectExpression | null = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import * as t from '@babel/types';
import type {NodePath} from '@babel/core';
import {traverse} from '@babel/core';
import {parse} from '@/application/project/code/transformation/javascript/utils/parse';
import {NuxtConfigModuleCodemod} from '@/application/project/code/transformation/javascript/nuxtConfigModuleCodemod';

export type NuxtConfig = {
srcDir?: string,
future?: {
compatibilityVersion?: number,
},
};

/**
* Parses the Nuxt configuration options that determine the project layout.
*
* The extraction is static and best-effort: an option is only reported when
* its value can be evaluated without running the configuration, such as
* literals and references to constants.
*/
export class NuxtConfigParser {
public parse(source: string): NuxtConfig {
let ast: t.File;

try {
ast = parse(source, ['typescript']);
} catch {
return {};
}

const config = NuxtConfigModuleCodemod.findConfig(ast);

if (config === null) {
return {};
}

const result: NuxtConfig = {};

traverse(ast, {
ObjectExpression: path => {
if (path.node !== config) {
return;
}

const srcDir = NuxtConfigParser.evaluateProperty(path, 'srcDir');

if (typeof srcDir === 'string') {
result.srcDir = srcDir;
}

const future = NuxtConfigParser.findProperty(path, 'future')?.get('value');

if (future !== undefined && future.isObjectExpression()) {
const version = NuxtConfigParser.evaluateProperty(future, 'compatibilityVersion');

if (typeof version === 'number') {
result.future = {
compatibilityVersion: version,
};
}
}

path.stop();
},
});

return result;
}

private static evaluateProperty(object: NodePath<t.ObjectExpression>, name: string): unknown {
const evaluation = NuxtConfigParser.findProperty(object, name)
?.get('value')
.evaluate();

return evaluation?.confident === true ? evaluation.value : undefined;
}

private static findProperty(
object: NodePath<t.ObjectExpression>,
name: string,
): NodePath<t.ObjectProperty> | null {
let match: NodePath<t.ObjectProperty> | null = null;

// Later properties override earlier ones, as in the evaluated object
for (const property of object.get('properties')) {
if (
property.isObjectProperty()
&& !property.node.computed
&& NuxtConfigParser.hasKey(property.node, name)
) {
match = property;
}
}

return match;
}

private static hasKey(property: t.ObjectProperty, name: string): boolean {
const {key} = property;

return (t.isIdentifier(key) && key.name === name) || (t.isStringLiteral(key) && key.value === name);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ export type NuxtStoryblokPluginConfiguration = {
module: string,
factory: string,
},
pluginName: string,
storyblokVueModule: string,
nuxtAppModule: string,
};

/**
* Scaffolds the Croct Storyblok plugin file for Nuxt.
*
* Generates the canonical plugin body that wires the Storyblok API into Croct
* inside a defineNuxtPlugin callback. Leaves hand-edited files untouched: if a
* plugin definition is already present, the codemod returns unmodified.
* Generates the canonical plugin that wires the Storyblok API into Croct.
* The plugin runs after the regular ones because the Storyblok API may be
* installed by an application plugin (e.g., `storyblok.ts`) rather than by
* the Storyblok module, and Nuxt runs application plugins in filename order.
* Leaves hand-edited files untouched: if the file already has a default
* export or a plugin definition, the codemod returns unmodified.
*/
export class NuxtStoryblokPluginCodemod implements Codemod<t.File, CodemodOptions> {
private readonly configuration: NuxtStoryblokPluginConfiguration;
Expand Down Expand Up @@ -50,7 +54,9 @@ export class NuxtStoryblokPluginCodemod implements Codemod<t.File, CodemodOption
importName: 'defineNuxtPlugin',
});

const pluginCallback = t.arrowFunctionExpression(
const setupMethod = t.objectMethod(
'method',
t.identifier('setup'),
[t.identifier('nuxtApp')],
t.blockStatement([
t.expressionStatement(
Expand All @@ -68,10 +74,16 @@ export class NuxtStoryblokPluginCodemod implements Codemod<t.File, CodemodOption
]),
);

const pluginDefinition = t.objectExpression([
t.objectProperty(t.identifier('name'), t.stringLiteral(this.configuration.pluginName)),
t.objectProperty(t.identifier('enforce'), t.stringLiteral('post')),
setupMethod,
]);

const defaultExport = t.exportDefaultDeclaration(
t.callExpression(
t.identifier(defineNuxtPluginImport.localName),
[pluginCallback],
[pluginDefinition],
),
);

Expand All @@ -91,6 +103,21 @@ export class NuxtStoryblokPluginCodemod implements Codemod<t.File, CodemodOption
let found = false;

traverse(ast, {
// A default export is the file's plugin, whatever its form
ExportDefaultDeclaration: path => {
found = true;

path.stop();
},
ExportSpecifier: path => {
const {exported} = path.node;

if ((t.isIdentifier(exported) ? exported.name : exported.value) === 'default') {
found = true;

path.stop();
}
},
CallExpression: path => {
const {callee} = path.node;

Expand Down
8 changes: 6 additions & 2 deletions src/application/project/sdk/javasScriptSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type JavaScriptPluginContext = {
packageManager: PackageManager,
projectDirectory: WorkingDirectory,
fileSystem: FileSystem,
paths: ProjectPaths,
};

export type JavaScriptSdkPlugin = {
Expand Down Expand Up @@ -318,14 +319,17 @@ export abstract class JavaScriptSdk implements Sdk {
return defaultPath;
}

private resolveInstallationPlan(installation: Installation): Promise<InstallationPlan> {
let promise = this.getInstallationPlan(installation);
private async resolveInstallationPlan(installation: Installation): Promise<InstallationPlan> {
const sdkPlan = await this.getInstallationPlan(installation);
const context: JavaScriptPluginContext = {
packageManager: this.packageManager,
projectDirectory: this.projectDirectory,
fileSystem: this.fileSystem,
paths: await this.getPaths(sdkPlan.configuration),
};

let promise = Promise.resolve(sdkPlan);

for (const plugin of this.plugins) {
promise = promise.then(async plan => {
const hookPlan = await plugin.getInstallationPlan(installation, context);
Expand Down
4 changes: 2 additions & 2 deletions src/application/project/sdk/nuxtStoryblokPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ export class NuxtStoryblokPlugin implements JavaScriptSdkPlugin {
}

private async scaffoldPluginFile(scope: JavaScriptPluginContext): Promise<void> {
const {fileSystem, projectDirectory} = scope;
const path = fileSystem.joinPaths(projectDirectory.get(), this.configuration.pluginFile);
const {fileSystem, projectDirectory, paths} = scope;
const path = fileSystem.joinPaths(projectDirectory.get(), paths.source, this.configuration.pluginFile);

if (!await fileSystem.exists(path)) {
await fileSystem.createDirectory(fileSystem.getDirectoryName(path), {recursive: true});
Expand Down
104 changes: 94 additions & 10 deletions src/application/project/sdk/plugNuxtSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ import type {Example} from '@/application/project/example/example';
import {UrlExample} from '@/application/project/example/example';
import {ApiKeyPermission} from '@/application/model/application';
import type {CommandExecutor} from '@/application/system/process/executor';
import type {ProjectConfiguration, ProjectPaths} from '@/application/project/configuration/projectConfiguration';
import type {
NuxtConfig,
NuxtConfigParser,
} from '@/application/project/code/transformation/javascript/nuxtConfigParser';

type CodemodConfiguration = {
config: Codemod<string>,
};

export type Configuration = JavaScriptSdkConfiguration & {
codemod: CodemodConfiguration,
configParser: NuxtConfigParser,
userApi: UserApi,
workspaceApi: WorkspaceApi,
applicationApi: ApplicationApi,
Expand All @@ -49,23 +55,42 @@ enum NuxtEnvVar {
}

export class PlugNuxtSdk extends JavaScriptSdk {
private static readonly CONFIG_FILES = ['ts', 'js', 'mjs'].map(extension => `nuxt.config.${extension}`);

private readonly userApi: UserApi;

private readonly applicationApi: ApplicationApi;

private readonly codemod: CodemodConfiguration;

private readonly configParser: NuxtConfigParser;

private readonly commandExecutor: CommandExecutor;

public constructor(configuration: Configuration) {
super(configuration);

this.codemod = configuration.codemod;
this.configParser = configuration.configParser;
this.userApi = configuration.userApi;
this.applicationApi = configuration.applicationApi;
this.commandExecutor = configuration.commandExecutor;
}

public async getPaths(configuration: ProjectConfiguration): Promise<ProjectPaths> {
const source = configuration.paths?.source ?? await this.getSourceDirectory();

return super.getPaths({
...configuration,
paths: {
...configuration.paths,
source: source,
// Examples are pages, which Nuxt routes automatically
examples: configuration.paths?.examples ?? this.fileSystem.joinPaths(source, 'pages'),
},
});
}

protected createExample(slot: Slot): Promise<Example> {
// Nuxt auto-routes `pages/<slug>/index.vue` to `/<slug>`.
return Promise.resolve(new UrlExample(slot.name, `/${slot.slug}`));
Expand All @@ -81,7 +106,12 @@ export class PlugNuxtSdk extends JavaScriptSdk {
const generator = new PlugNuxtExampleGenerator({
typescript: isTypeScript,
contentVariable: 'data.content',
slotImportPath: this.fileSystem.joinPaths('~', paths.components, '%slug%.vue'),
// Nuxt resolves `~` to the source directory
slotImportPath: this.fileSystem.joinPaths(
'~',
this.fileSystem.getRelativePath(paths.source, paths.components),
'%slug%.vue',
),
slotFilePath: slotPath,
slotComponentName: '%name%',
pageFilePath: pagePath,
Expand All @@ -106,13 +136,7 @@ export class PlugNuxtSdk extends JavaScriptSdk {
...installation,
project: projectInfo,
}),
configuration: {
...configuration,
paths: {
...configuration.paths,
examples: 'pages',
},
},
configuration: configuration,
};
}

Expand All @@ -138,9 +162,69 @@ export class PlugNuxtSdk extends JavaScriptSdk {
}

private async locateNuxtConfig(): Promise<string | null> {
return this.locateFile(
...['ts', 'js', 'mjs'].map(ext => `nuxt.config.${ext}`),
return this.locateFile(...PlugNuxtSdk.CONFIG_FILES);
}

private async getConfig(): Promise<NuxtConfig> {
const source = await this.readFile(...PlugNuxtSdk.CONFIG_FILES).catch(() => null);

return source === null ? {} : this.configParser.parse(source);
}

/**
* Resolves the source directory following the rules Nuxt applies to `srcDir`.
*
* Unless configured otherwise, Nuxt 4, and Nuxt 3 opted into the version 4
* behavior, use `app/` as the source directory, falling back to the root
* directory for projects that still follow the previous layout.
*/
private async getSourceDirectory(): Promise<string> {
const [config, isNuxt4] = await Promise.all([
this.getConfig(),
// Includes the 4.0 pre-releases
this.packageManager.hasDirectDependency('nuxt', '>=4.0.0-0'),
]);

if (config.srcDir !== undefined) {
const root = this.projectDirectory.get();
const directory = this.fileSystem.getRelativePath(root, this.fileSystem.joinPaths(root, config.srcDir));

return directory === '' ? '.' : directory;
}

if (!isNuxt4 && config.future?.compatibilityVersion !== 4) {
return '.';
}

return await this.usesAppDirectory() ? 'app' : '.';
}

private async usesAppDirectory(): Promise<boolean> {
const directory = this.fileSystem.joinPaths(this.projectDirectory.get(), 'app');

if (!await this.fileSystem.isDirectory(directory)) {
return false;
}

for await (const entry of this.fileSystem.list(directory, (_, depth) => depth === 0)) {
// Nuxt 3 already kept these files in `app/`, so they do not indicate the new layout
if (entry.name !== 'spa-loading-template.html' && !entry.name.startsWith('router.options')) {
return true;
}
}

// Otherwise, `app/` is the source directory unless the root follows the previous layout
const rootLayoutEntry = await this.locateFile(
'app.vue',
'App.vue',
'assets',
'layouts',
'middleware',
'pages',
'plugins',
);

return rootLayoutEntry === null;
}

private getInstallationTasks(installation: Omit<NuxtInstallation, 'notifier'>): Task[] {
Expand Down
Loading
Loading