diff --git a/docs/2.guide/2.directory-structure/1.layouts.md b/docs/2.guide/2.directory-structure/1.layouts.md index f6d961d08b..4d77c15788 100644 --- a/docs/2.guide/2.directory-structure/1.layouts.md +++ b/docs/2.guide/2.directory-structure/1.layouts.md @@ -178,3 +178,24 @@ definePageMeta({ ::alert{type=warning} If you use `` within your pages, make sure it is not the root element (or disable layout/page transitions). :: + +## Layout Names + +If you have a layout in nested directories such as: + +```bash +| layouts/ +--| base/ +----| foo/ +------| Layout.vue +``` + +... then the layout's name will be based on its own path directory and filename, with duplicate segments being removed. Therefore, the layout's name will be: + +```html + +``` + +::alert +For clarity, we recommend that the layout's filename matches its name. (So, in the example above, you could rename `Layout.vue` to be `BaseFooLayout.vue`.) +:: diff --git a/packages/nuxt/src/components/scan.ts b/packages/nuxt/src/components/scan.ts index a5093b9305..b4fe7d873c 100644 --- a/packages/nuxt/src/components/scan.ts +++ b/packages/nuxt/src/components/scan.ts @@ -8,6 +8,8 @@ import { hyphenate } from '@vue/shared' import { withTrailingSlash } from 'ufo' import type { Component, ComponentsDir } from 'nuxt/schema' +import { resolveComponentName } from '../core/utils' + /** * Scan the components inside different components folders * and return a unique list of components @@ -157,33 +159,6 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr return components } -export function resolveComponentName (fileName: string, prefixParts: string[]) { - /** - * Array of fileName parts splitted by case, / or - - * @example third-component -> ['third', 'component'] - * @example AwesomeComponent -> ['Awesome', 'Component'] - */ - const fileNameParts = splitByCase(fileName) - const fileNamePartsContent = fileNameParts.join('/').toLowerCase() - const componentNameParts: string[] = [...prefixParts] - let index = prefixParts.length - 1 - const matchedSuffix: string[] = [] - while (index >= 0) { - matchedSuffix.unshift(...splitByCase(prefixParts[index] || '').map(p => p.toLowerCase())) - const matchedSuffixContent = matchedSuffix.join('/') - if ((fileNamePartsContent === matchedSuffixContent || fileNamePartsContent.startsWith(matchedSuffixContent + '/')) || - // e.g Item/Item/Item.vue -> Item - (prefixParts[index].toLowerCase() === fileNamePartsContent && - prefixParts[index + 1] && - prefixParts[index] === prefixParts[index + 1])) { - componentNameParts.length = index - } - index-- - } - - return pascalCase(componentNameParts) + pascalCase(fileNameParts) -} - function warnAboutDuplicateComponent (componentName: string, filePath: string, duplicatePath: string) { logger.warn(`Two component files resolving to the same name \`${componentName}\`:\n` + `\n - ${filePath}` + diff --git a/packages/nuxt/src/core/app.ts b/packages/nuxt/src/core/app.ts index 655c73fa35..402bca7f1b 100644 --- a/packages/nuxt/src/core/app.ts +++ b/packages/nuxt/src/core/app.ts @@ -110,9 +110,9 @@ export async function resolveApp (nuxt: Nuxt, app: NuxtApp) { app.layouts = {} for (const config of nuxt.options._layers.map(layer => layer.config)) { const layoutDir = (config.rootDir === nuxt.options.rootDir ? nuxt.options : config).dir?.layouts || 'layouts' - const layoutFiles = await resolveFiles(config.srcDir, `${layoutDir}/*{${nuxt.options.extensions.join(',')}}`) + const layoutFiles = await resolveFiles(config.srcDir, `${layoutDir}/**/*{${nuxt.options.extensions.join(',')}}`) for (const file of layoutFiles) { - const name = getNameFromPath(file) + const name = getNameFromPath(file, resolve(config.srcDir, layoutDir)) app.layouts[name] = app.layouts[name] || { name, file } } } diff --git a/packages/nuxt/src/core/utils/names.ts b/packages/nuxt/src/core/utils/names.ts index 82707511d3..abead3684d 100644 --- a/packages/nuxt/src/core/utils/names.ts +++ b/packages/nuxt/src/core/utils/names.ts @@ -1,10 +1,43 @@ -import { basename, extname } from 'pathe' -import { kebabCase } from 'scule' +import { basename, dirname, extname, normalize } from 'pathe' +import { kebabCase, pascalCase, splitByCase } from 'scule' +import { withTrailingSlash } from 'ufo' -export function getNameFromPath (path: string) { - return kebabCase(basename(path).replace(extname(path), '')).replace(/["']/g, '') +export function getNameFromPath (path: string, relativeTo?: string) { + const relativePath = relativeTo + ? normalize(path).replace(withTrailingSlash(normalize(relativeTo)), '') + : basename(path) + const prefixParts = splitByCase(dirname(relativePath)) + const fileName = basename(relativePath, extname(relativePath)) + return kebabCase(resolveComponentName(fileName.toLowerCase() === 'index' ? '' : fileName, prefixParts)).replace(/["']/g, '') } export function hasSuffix (path: string, suffix: string) { return basename(path).replace(extname(path), '').endsWith(suffix) } + +export function resolveComponentName (fileName: string, prefixParts: string[]) { + /** + * Array of fileName parts splitted by case, / or - + * @example third-component -> ['third', 'component'] + * @example AwesomeComponent -> ['Awesome', 'Component'] + */ + const fileNameParts = splitByCase(fileName) + const fileNamePartsContent = fileNameParts.join('/').toLowerCase() + const componentNameParts: string[] = [...prefixParts] + let index = prefixParts.length - 1 + const matchedSuffix: string[] = [] + while (index >= 0) { + matchedSuffix.unshift(...splitByCase(prefixParts[index] || '').map(p => p.toLowerCase())) + const matchedSuffixContent = matchedSuffix.join('/') + if ((fileNamePartsContent === matchedSuffixContent || fileNamePartsContent.startsWith(matchedSuffixContent + '/')) || + // e.g Item/Item/Item.vue -> Item + (prefixParts[index].toLowerCase() === fileNamePartsContent && + prefixParts[index + 1] && + prefixParts[index] === prefixParts[index + 1])) { + componentNameParts.length = index + } + index-- + } + + return pascalCase(componentNameParts) + pascalCase(fileNameParts) +} diff --git a/packages/nuxt/test/app.test.ts b/packages/nuxt/test/app.test.ts index c72091a80e..bc2c193f64 100644 --- a/packages/nuxt/test/app.test.ts +++ b/packages/nuxt/test/app.test.ts @@ -179,6 +179,55 @@ describe('resolveApp', () => { } `) }) + + it('resolves nested layouts correctly', async () => { + const app = await getResolvedApp([ + 'layouts/default.vue', + 'layouts/some/layout.vue', + 'layouts/SomeOther.vue', + 'layouts/SomeOther/Thing/Index.vue', + 'layouts/thing/thing/thing.vue', + 'layouts/desktop-base/base.vue', + 'layouts/some.vue', + 'layouts/SomeOther/layout.ts' + ]) + expect(app.layouts).toMatchInlineSnapshot(` + { + "default": { + "file": "/layouts/default.vue", + "name": "default", + }, + "desktop-base": { + "file": "/layouts/desktop-base/base.vue", + "name": "desktop-base", + }, + "some": { + "file": "/layouts/some.vue", + "name": "some", + }, + "some-layout": { + "file": "/layouts/some/layout.vue", + "name": "some-layout", + }, + "some-other": { + "file": "/layouts/SomeOther.vue", + "name": "some-other", + }, + "some-other-layout": { + "file": "/layouts/SomeOther/layout.ts", + "name": "some-other-layout", + }, + "some-other-thing": { + "file": "/layouts/SomeOther/Thing/Index.vue", + "name": "some-other-thing", + }, + "thing": { + "file": "/layouts/thing/thing/thing.vue", + "name": "thing", + }, + } + `) + }) }) async function getResolvedApp (files: Array) { diff --git a/packages/nuxt/test/scan-components.test.ts b/packages/nuxt/test/scan-components.test.ts index 97cd60a472..47fad31b01 100644 --- a/packages/nuxt/test/scan-components.test.ts +++ b/packages/nuxt/test/scan-components.test.ts @@ -2,7 +2,8 @@ import { resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { ComponentsDir } from 'nuxt/schema' -import { resolveComponentName, scanComponents } from '../src/components/scan' +import { scanComponents } from '../src/components/scan' +import { resolveComponentName } from '../src/core/utils' const fixtureDir = resolve(__dirname, 'fixture') const rFixture = (...p: string[]) => resolve(fixtureDir, ...p)