Add trailingSlash & pageDirectoryUrl config options (#1197)

This commit is contained in:
Fred K. Schott 2021-08-23 12:05:01 -07:00 committed by GitHub
parent 1185d8ffcb
commit c06da5dd78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 284 additions and 92 deletions

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Add configuration options for url format behavior: buildOptions.pageDirectoryUrl & trailingSlash

View file

@ -0,0 +1,5 @@
---
'astro': patch
---
Move 404.html output from /404/index.html to /404.html

View file

@ -1 +0,0 @@
<h1>hello</h1>

View file

@ -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',
],
};
```

View file

@ -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<AstroMarkdownOptions>;
/** 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<AstroConfig, 'buildOptions' | 'devOptions'> & {
buildOptions: {

View file

@ -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<AstroMarkdownOptions>;
/** 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';
};
}

View file

@ -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<void> {
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',

View file

@ -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);
});
}

View file

@ -55,17 +55,19 @@ function validateConfig(config: any): void {
async function configDefaults(userConfig?: any): Promise<any> {
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;
}

View file

@ -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;
}

View file

@ -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();

View file

@ -0,0 +1,5 @@
export default {
buildOptions: {
pageDirectoryUrl: false
}
};

View file

@ -0,0 +1,3 @@
{
"workspaceRoot": "../../../../../"
}

View file

@ -0,0 +1,7 @@
<html>
<head>
<title>Stuff</title>
</head>
<body>
</body>
</html>

View file

@ -0,0 +1,12 @@
---
let title = 'Nested page'
---
<html>
<head>
<!-- Head Stuff -->
</head>
<body>
<h1>{title}</h1>
</body>
</html>

View file

@ -0,0 +1,5 @@
---
title: My Page
---
Hello world

View file

@ -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),