diff --git a/.changeset/soft-goats-wash.md b/.changeset/soft-goats-wash.md
new file mode 100644
index 0000000000..eaff57242a
--- /dev/null
+++ b/.changeset/soft-goats-wash.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Add configuration options for url format behavior: buildOptions.pageDirectoryUrl & trailingSlash
diff --git a/.changeset/tough-dancers-hear.md b/.changeset/tough-dancers-hear.md
new file mode 100644
index 0000000000..0d90dae100
--- /dev/null
+++ b/.changeset/tough-dancers-hear.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Move 404.html output from /404/index.html to /404.html
diff --git a/docs/src/pages/foo/index.astro b/docs/src/pages/foo/index.astro
deleted file mode 100644
index 653debaa84..0000000000
--- a/docs/src/pages/foo/index.astro
+++ /dev/null
@@ -1 +0,0 @@
-
hello
\ No newline at end of file
diff --git a/docs/src/pages/reference/configuration-reference.md b/docs/src/pages/reference/configuration-reference.md
index ef851f8481..e4591f7c4d 100644
--- a/docs/src/pages/reference/configuration-reference.md
+++ b/docs/src/pages/reference/configuration-reference.md
@@ -3,30 +3,18 @@ layout: ~/layouts/MainLayout.astro
title: Configuration Reference
---
-To configure Astro, add an `astro.config.mjs` file in the root of your project. All settings are optional. Here are the defaults:
+To configure Astro, add an `astro.config.mjs` file in the root of your project. All settings are optional.
+
+You can view the full configuration API (including information about default configuration) on GitHub: https://github.com/snowpackjs/astro/blob/latest/packages/astro/src/@types/config.ts
```js
+// Example: astro.config.mjs
+
+/** @type {import('astro').AstroUserConfig} */
export default {
- projectRoot: '.', // Where to resolve all URLs relative to. Useful if you have a monorepo project.
- src: './src', // Path to Astro components, pages, and data
- pages: './src/pages', // Path to Astro/Markdown pages
- dist: './dist', // When running `astro build`, path to final static output
- public: './public', // A folder of static files Astro will copy to the root. Useful for favicons, images, and other files that don't need processing.
buildOptions: {
- // site: '', // Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs.
- sitemap: true, // Generate sitemap (set to "false" to disable)
+ site: 'https://example.com',
},
- devOptions: {
- port: 3000, // The port to run the dev server on.
- // tailwindConfig: '', // Path to tailwind.config.js if used, e.g. './tailwind.config.js'
- },
- // component renderers which are enabled by default
- renderers: [
- '@astrojs/renderer-svelte',
- '@astrojs/renderer-vue',
- '@astrojs/renderer-react',
- '@astrojs/renderer-preact',
- ],
};
```
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index f14f73e428..d9b3500de9 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -1,5 +1,6 @@
import type { ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier } from '@babel/types';
import type { AstroMarkdownOptions } from '@astrojs/markdown-support';
+import type { AstroConfig } from './config';
export interface RouteData {
type: 'page';
@@ -22,33 +23,7 @@ export interface AstroConfigRaw {
jsx?: string;
}
-export { AstroMarkdownOptions };
-export interface AstroConfig {
- dist: string;
- projectRoot: URL;
- pages: URL;
- public: URL;
- src: URL;
- renderers?: string[];
- /** Options for rendering markdown content */
- markdownOptions?: Partial;
- /** Options specific to `astro build` */
- buildOptions: {
- /** Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs. */
- site?: string;
- /** Generate sitemap (set to "false" to disable) */
- sitemap: boolean;
- };
- /** Options for the development server run with `astro dev`. */
- devOptions: {
- hostname?: string;
- /** The port to run the dev server on. */
- port: number;
- projectRoot?: string;
- /** Path to tailwind.config.js, if used */
- tailwindConfig?: string;
- };
-}
+export { AstroMarkdownOptions, AstroConfig };
export type AstroUserConfig = Omit & {
buildOptions: {
diff --git a/packages/astro/src/@types/config.ts b/packages/astro/src/@types/config.ts
new file mode 100644
index 0000000000..a3699ad04e
--- /dev/null
+++ b/packages/astro/src/@types/config.ts
@@ -0,0 +1,75 @@
+import type { AstroMarkdownOptions } from '@astrojs/markdown-support';
+export interface AstroConfig {
+ /**
+ * Where to resolve all URLs relative to. Useful if you have a monorepo project.
+ * Default: '.' (current working directory)
+ */
+ projectRoot: URL;
+ /**
+ * Path to the `astro build` output.
+ * Default: './dist'
+ */
+ dist: string;
+ /**
+ * Path to all of your Astro components, pages, and data.
+ * Default: './src'
+ */
+ src: URL;
+ /**
+ * Path to your Astro/Markdown pages. Each file in this directory
+ * becomes a page in your final build.
+ * Default: './src/pages'
+ */
+ pages: URL;
+ /**
+ * Path to your public files. These are copied over into your build directory, untouched.
+ * Useful for favicons, images, and other files that don't need processing.
+ * Default: './public'
+ */
+ public: URL;
+ /**
+ * Framework component renderers enable UI framework rendering (static and dynamic).
+ * When you define this in your configuration, all other defaults are disabled.
+ * Default: [
+ * '@astrojs/renderer-svelte',
+ * '@astrojs/renderer-vue',
+ * '@astrojs/renderer-react',
+ * '@astrojs/renderer-preact',
+ * ],
+ */
+ renderers?: string[];
+ /** Options for rendering markdown content */
+ markdownOptions?: Partial;
+ /** Options specific to `astro build` */
+ buildOptions: {
+ /** Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs. */
+ site?: string;
+ /** Generate an automatically-generated sitemap for your build.
+ * Default: true
+ */
+ sitemap: boolean;
+ /**
+ * Control the output file/URL format of each page.
+ * If true, Astro will generate a directory with a nested index.html (ex: "/foo/index.html") for each page.
+ * If false, Astro will generate a matching HTML file (ex: "/foo.html") instead of a directory.
+ * Default: true
+ */
+ pageDirectoryUrl: boolean;
+ };
+ /** Options for the development server run with `astro dev`. */
+ devOptions: {
+ hostname?: string;
+ /** The port to run the dev server on. */
+ port: number;
+ /** Path to tailwind.config.js, if used */
+ tailwindConfig?: string;
+ /**
+ * Configure The trailing slash behavior of URL route matching:
+ * 'always' - Only match URLs that include a trailing slash (ex: "/foo/")
+ * 'never' - Never match URLs that include a trailing slash (ex: "/foo")
+ * 'ignore' - Match URLs regardless of whether a trailing "/" exists
+ * Default: 'always'
+ */
+ trailingSlash: 'always' | 'never' | 'ignore';
+ };
+}
diff --git a/packages/astro/src/build/page.ts b/packages/astro/src/build/page.ts
index 315d0c34af..73bb9a189f 100644
--- a/packages/astro/src/build/page.ts
+++ b/packages/astro/src/build/page.ts
@@ -45,18 +45,30 @@ export async function getStaticPathsForPage({
};
}
+function formatOutFile(path: string, pageDirectoryUrl: boolean) {
+ if (path === '/404') {
+ return '/404.html';
+ }
+ if (path === '/') {
+ return '/index.html';
+ }
+ if (pageDirectoryUrl) {
+ return _path.posix.join(path, '/index.html');
+ }
+ return `${path}.html`;
+}
/** Build static page */
export async function buildStaticPage({ astroConfig, buildState, path, route, astroRuntime }: PageBuildOptions): Promise {
const location = convertMatchToLocation(route, astroConfig);
- const result = await astroRuntime.load(path);
+ const normalizedPath = astroConfig.devOptions.trailingSlash === 'never' ? path : path.endsWith('/') ? path : `${path}/`;
+ const result = await astroRuntime.load(normalizedPath);
if (result.statusCode !== 200) {
let err = (result as any).error;
if (!(err instanceof Error)) err = new Error(err);
err.filename = fileURLToPath(location.fileURL);
throw err;
}
- const outFile = _path.posix.join(path, '/index.html');
- buildState[outFile] = {
+ buildState[formatOutFile(path, astroConfig.buildOptions.pageDirectoryUrl)] = {
srcPath: location.fileURL,
contents: result.contents,
contentType: 'text/html',
diff --git a/packages/astro/src/build/stats.ts b/packages/astro/src/build/stats.ts
index 0dd07ef7db..1e67e0e1a2 100644
--- a/packages/astro/src/build/stats.ts
+++ b/packages/astro/src/build/stats.ts
@@ -72,20 +72,21 @@ export async function collectBundleStats(buildState: BuildOutput, depTree: Bundl
}
export function logURLStats(logging: LogOptions, urlStats: URLStatsMap) {
- const builtURLs = [...urlStats.keys()].map((url) => url.replace(/index\.html$/, ''));
- builtURLs.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
+ const builtURLs = [...urlStats.keys()].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
info(logging, null, '');
const log = table(logging, [60, 20]);
log(info, ' ' + bold(underline('Pages')), bold(underline('Page Weight (GZip)')));
-
const lastIndex = builtURLs.length - 1;
builtURLs.forEach((url, index) => {
const sep = index === 0 ? '┌' : index === lastIndex ? '└' : '├';
const urlPart = ' ' + sep + ' ' + url;
-
- const bytes = (urlStats.get(url) || urlStats.get(url + 'index.html'))?.stats.map((s) => s.gzipSize).reduce((a, b) => a + b, 0) || 0;
+ const bytes =
+ urlStats
+ .get(url)
+ ?.stats.map((s) => s.gzipSize)
+ .reduce((a, b) => a + b, 0) || 0;
const kb = (bytes * 0.001).toFixed(2);
const sizePart = kb + ' kB';
- log(info, urlPart + 'index.html', sizePart);
+ log(info, urlPart, sizePart);
});
}
diff --git a/packages/astro/src/config.ts b/packages/astro/src/config.ts
index b41d670031..11e28b1682 100644
--- a/packages/astro/src/config.ts
+++ b/packages/astro/src/config.ts
@@ -55,17 +55,19 @@ function validateConfig(config: any): void {
async function configDefaults(userConfig?: any): Promise {
const config: any = { ...(userConfig || {}) };
- if (!config.projectRoot) config.projectRoot = '.';
- if (!config.src) config.src = './src';
- if (!config.pages) config.pages = './src/pages';
- if (!config.dist) config.dist = './dist';
- if (!config.public) config.public = './public';
- if (!config.devOptions) config.devOptions = {};
- if (!config.devOptions.port) config.devOptions.port = await getPort({ port: getPort.makeRange(3000, 3050) });
- if (!config.devOptions.hostname) config.devOptions.hostname = 'localhost';
- if (!config.buildOptions) config.buildOptions = {};
- if (!config.markdownOptions) config.markdownOptions = {};
- if (typeof config.buildOptions.sitemap === 'undefined') config.buildOptions.sitemap = true;
+ if (config.projectRoot === undefined) config.projectRoot = '.';
+ if (config.src === undefined) config.src = './src';
+ if (config.pages === undefined) config.pages = './src/pages';
+ if (config.dist === undefined) config.dist = './dist';
+ if (config.public === undefined) config.public = './public';
+ if (config.devOptions === undefined) config.devOptions = {};
+ if (config.devOptions.port === undefined) config.devOptions.port = await getPort({ port: getPort.makeRange(3000, 3050) });
+ if (config.devOptions.hostname === undefined) config.devOptions.hostname = 'localhost';
+ if (config.devOptions.trailingSlash === undefined) config.devOptions.trailingSlash = 'ignore';
+ if (config.buildOptions === undefined) config.buildOptions = {};
+ if (config.buildOptions.pageDirectoryUrl === undefined) config.buildOptions.pageDirectoryUrl = true;
+ if (config.markdownOptions === undefined) config.markdownOptions = {};
+ if (config.buildOptions.sitemap === undefined) config.buildOptions.sitemap = true;
return config;
}
diff --git a/packages/astro/src/manifest/create.ts b/packages/astro/src/manifest/create.ts
index adb25ac0ac..4faf473389 100644
--- a/packages/astro/src/manifest/create.ts
+++ b/packages/astro/src/manifest/create.ts
@@ -119,8 +119,8 @@ export function createManifest({ config, cwd }: { config: AstroConfig; cwd?: str
} else {
components.push(item.file);
const component = item.file;
- const pattern = getPattern(segments, true);
- const generate = getGenerator(segments, false);
+ const pattern = getPattern(segments, config.devOptions.trailingSlash);
+ const generate = getGenerator(segments, config.devOptions.trailingSlash);
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` : null;
routes.push({
@@ -218,7 +218,17 @@ function getParts(part: string, file: string) {
return result;
}
-function getPattern(segments: Part[][], addTrailingSlash: boolean) {
+function getTrailingSlashPattern(addTrailingSlash: AstroConfig['devOptions']['trailingSlash']): string {
+ if (addTrailingSlash === 'always') {
+ return '\\/$';
+ }
+ if (addTrailingSlash === 'never') {
+ return '$';
+ }
+ return '\\/?$';
+}
+
+function getPattern(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) {
const pathname = segments
.map((segment) => {
return segment[0].spread
@@ -241,11 +251,11 @@ function getPattern(segments: Part[][], addTrailingSlash: boolean) {
})
.join('');
- const trailing = addTrailingSlash && segments.length ? '\\/?$' : '$';
+ const trailing = addTrailingSlash && segments.length ? getTrailingSlashPattern(addTrailingSlash) : '$';
return new RegExp(`^${pathname || '\\/'}${trailing}`);
}
-function getGenerator(segments: Part[][], addTrailingSlash: boolean) {
+function getGenerator(segments: Part[][], addTrailingSlash: AstroConfig['devOptions']['trailingSlash']) {
const template = segments
.map((segment) => {
return segment[0].spread
@@ -268,7 +278,7 @@ function getGenerator(segments: Part[][], addTrailingSlash: boolean) {
})
.join('');
- const trailing = addTrailingSlash && segments.length ? '/' : '';
+ const trailing = addTrailingSlash !== 'never' && segments.length ? '/' : '';
const toPath = compile(template + trailing);
return toPath;
}
diff --git a/packages/astro/test/astro-pageDirectoryUrl.test.js b/packages/astro/test/astro-pageDirectoryUrl.test.js
new file mode 100644
index 0000000000..0dde1286ff
--- /dev/null
+++ b/packages/astro/test/astro-pageDirectoryUrl.test.js
@@ -0,0 +1,16 @@
+import { suite } from 'uvu';
+import * as assert from 'uvu/assert';
+import { setupBuild } from './helpers.js';
+
+const PageDirectoryUrl = suite('pageDirectoryUrl');
+
+setupBuild(PageDirectoryUrl, './fixtures/astro-page-directory-url');
+
+PageDirectoryUrl('outputs', async ({ build, readFile }) => {
+ await build();
+ assert.ok(await readFile('/client.html'));
+ assert.ok(await readFile('/nested-md.html'));
+ assert.ok(await readFile('/nested-astro.html'));
+});
+
+PageDirectoryUrl.run();
diff --git a/packages/astro/test/fixtures/astro-page-directory-url/astro.config.mjs b/packages/astro/test/fixtures/astro-page-directory-url/astro.config.mjs
new file mode 100644
index 0000000000..995a3dc467
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-page-directory-url/astro.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ buildOptions: {
+ pageDirectoryUrl: false
+ }
+};
diff --git a/packages/astro/test/fixtures/astro-page-directory-url/snowpack.config.json b/packages/astro/test/fixtures/astro-page-directory-url/snowpack.config.json
new file mode 100644
index 0000000000..8f034781d8
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-page-directory-url/snowpack.config.json
@@ -0,0 +1,3 @@
+{
+ "workspaceRoot": "../../../../../"
+}
diff --git a/packages/astro/test/fixtures/astro-page-directory-url/src/pages/client.astro b/packages/astro/test/fixtures/astro-page-directory-url/src/pages/client.astro
new file mode 100644
index 0000000000..8d05b0b5e7
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-page-directory-url/src/pages/client.astro
@@ -0,0 +1,7 @@
+
+
+Stuff
+
+
+
+
\ No newline at end of file
diff --git a/packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-astro/index.astro b/packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-astro/index.astro
new file mode 100644
index 0000000000..a28992ee6b
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-astro/index.astro
@@ -0,0 +1,12 @@
+---
+let title = 'Nested page'
+---
+
+
+
+
+
+
+ {title}
+
+
diff --git a/packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-md/index.md b/packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-md/index.md
new file mode 100644
index 0000000000..57417979fe
--- /dev/null
+++ b/packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-md/index.md
@@ -0,0 +1,5 @@
+---
+title: My Page
+---
+
+Hello world
diff --git a/packages/astro/test/route-manifest.test.js b/packages/astro/test/route-manifest.test.js
index ccf5ee04db..7f110bba6b 100644
--- a/packages/astro/test/route-manifest.test.js
+++ b/packages/astro/test/route-manifest.test.js
@@ -5,16 +5,14 @@ import { createManifest } from '../dist/manifest/create.js';
const cwd = new URL('./fixtures/route-manifest/', import.meta.url);
-/**
- * @param {string} dir
- * @param {string[]} [extensions]
- * @returns
- */
-const create = (dir) => {
+const create = (dir, trailingSlash) => {
return createManifest({
config: {
projectRoot: cwd,
pages: new URL(dir, cwd),
+ devOptions: {
+ trailingSlash,
+ },
},
cwd: fileURLToPath(cwd),
});
@@ -26,8 +24,82 @@ function cleanRoutes(routes) {
});
}
-test('creates routes', () => {
- const { routes } = create('basic');
+test('creates routes with trailingSlashes = always', () => {
+ const { routes } = create('basic', 'always');
+ assert.equal(cleanRoutes(routes), [
+ {
+ type: 'page',
+ pattern: /^\/$/,
+ params: [],
+ component: 'basic/index.astro',
+ path: '/',
+ },
+
+ {
+ type: 'page',
+ pattern: /^\/about\/$/,
+ params: [],
+ component: 'basic/about.astro',
+ path: '/about',
+ },
+
+ {
+ type: 'page',
+ pattern: /^\/blog\/$/,
+ params: [],
+ component: 'basic/blog/index.astro',
+ path: '/blog',
+ },
+
+ {
+ type: 'page',
+ pattern: /^\/blog\/([^/]+?)\/$/,
+ params: ['slug'],
+ component: 'basic/blog/[slug].astro',
+ path: null,
+ },
+ ]);
+});
+
+test('creates routes with trailingSlashes = never', () => {
+ const { routes } = create('basic', 'never');
+ assert.equal(cleanRoutes(routes), [
+ {
+ type: 'page',
+ pattern: /^\/$/,
+ params: [],
+ component: 'basic/index.astro',
+ path: '/',
+ },
+
+ {
+ type: 'page',
+ pattern: /^\/about$/,
+ params: [],
+ component: 'basic/about.astro',
+ path: '/about',
+ },
+
+ {
+ type: 'page',
+ pattern: /^\/blog$/,
+ params: [],
+ component: 'basic/blog/index.astro',
+ path: '/blog',
+ },
+
+ {
+ type: 'page',
+ pattern: /^\/blog\/([^/]+?)$/,
+ params: ['slug'],
+ component: 'basic/blog/[slug].astro',
+ path: null,
+ },
+ ]);
+});
+
+test('creates routes with trailingSlashes = ignore', () => {
+ const { routes } = create('basic', 'ignore');
assert.equal(cleanRoutes(routes), [
{
type: 'page',
@@ -64,7 +136,7 @@ test('creates routes', () => {
});
test('encodes invalid characters', () => {
- const { routes } = create('encoding');
+ const { routes } = create('encoding', 'always');
// had to remove ? and " because windows
@@ -76,34 +148,34 @@ test('encodes invalid characters', () => {
routes.map((p) => p.pattern),
[
// /^\/%22$/,
- /^\/%23\/?$/,
+ /^\/%23\/$/,
// /^\/%3F$/
]
);
});
test('ignores files and directories with leading underscores', () => {
- const { routes } = create('hidden-underscore');
+ const { routes } = create('hidden-underscore', 'always');
assert.equal(routes.map((r) => r.component).filter(Boolean), ['hidden-underscore/index.astro', 'hidden-underscore/e/f/g/h.astro']);
});
test('ignores files and directories with leading dots except .well-known', () => {
- const { routes } = create('hidden-dot');
+ const { routes } = create('hidden-dot', 'always');
assert.equal(routes.map((r) => r.component).filter(Boolean), ['hidden-dot/.well-known/dnt-policy.astro']);
});
test('fails if dynamic params are not separated', () => {
assert.throws(() => {
- create('invalid-params');
+ create('invalid-params', 'always');
}, /Invalid route invalid-params\/\[foo\]\[bar\]\.astro — parameters must be separated/);
});
test('disallows rest parameters inside segments', () => {
assert.throws(
() => {
- create('invalid-rest');
+ create('invalid-rest', 'always');
},
/** @param {Error} e */
(e) => {
@@ -113,11 +185,11 @@ test('disallows rest parameters inside segments', () => {
});
test('ignores things that look like lockfiles', () => {
- const { routes } = create('lockfiles');
+ const { routes } = create('lockfiles', 'always');
assert.equal(cleanRoutes(routes), [
{
type: 'page',
- pattern: /^\/foo\/?$/,
+ pattern: /^\/foo\/$/,
params: [],
component: 'lockfiles/foo.astro',
path: '/foo',
@@ -126,12 +198,12 @@ test('ignores things that look like lockfiles', () => {
});
test('allows multiple slugs', () => {
- const { routes } = create('multiple-slugs');
+ const { routes } = create('multiple-slugs', 'always');
assert.equal(cleanRoutes(routes), [
{
type: 'page',
- pattern: /^\/([^/]+?)\.([^/]+?)\/?$/,
+ pattern: /^\/([^/]+?)\.([^/]+?)\/$/,
component: 'multiple-slugs/[file].[ext].astro',
params: ['file', 'ext'],
path: null,
@@ -140,7 +212,7 @@ test('allows multiple slugs', () => {
});
test('sorts routes correctly', () => {
- const { routes } = create('sorting');
+ const { routes } = create('sorting', 'always');
assert.equal(
routes.map((p) => p.component),