From c06da5dd7856f0db3f2c5302ccea89a18be20fe4 Mon Sep 17 00:00:00 2001 From: "Fred K. Schott" Date: Mon, 23 Aug 2021 12:05:01 -0700 Subject: [PATCH] Add trailingSlash & pageDirectoryUrl config options (#1197) --- .changeset/soft-goats-wash.md | 5 + .changeset/tough-dancers-hear.md | 5 + docs/src/pages/foo/index.astro | 1 - .../reference/configuration-reference.md | 26 ++--- packages/astro/src/@types/astro.ts | 29 +---- packages/astro/src/@types/config.ts | 75 ++++++++++++ packages/astro/src/build/page.ts | 18 ++- packages/astro/src/build/stats.ts | 13 ++- packages/astro/src/config.ts | 24 ++-- packages/astro/src/manifest/create.ts | 22 +++- .../astro/test/astro-pageDirectoryUrl.test.js | 16 +++ .../astro-page-directory-url/astro.config.mjs | 5 + .../snowpack.config.json | 3 + .../src/pages/client.astro | 7 ++ .../src/pages/nested-astro/index.astro | 12 ++ .../src/pages/nested-md/index.md | 5 + packages/astro/test/route-manifest.test.js | 110 +++++++++++++++--- 17 files changed, 284 insertions(+), 92 deletions(-) create mode 100644 .changeset/soft-goats-wash.md create mode 100644 .changeset/tough-dancers-hear.md delete mode 100644 docs/src/pages/foo/index.astro create mode 100644 packages/astro/src/@types/config.ts create mode 100644 packages/astro/test/astro-pageDirectoryUrl.test.js create mode 100644 packages/astro/test/fixtures/astro-page-directory-url/astro.config.mjs create mode 100644 packages/astro/test/fixtures/astro-page-directory-url/snowpack.config.json create mode 100644 packages/astro/test/fixtures/astro-page-directory-url/src/pages/client.astro create mode 100644 packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-astro/index.astro create mode 100644 packages/astro/test/fixtures/astro-page-directory-url/src/pages/nested-md/index.md diff --git a/.changeset/soft-goats-wash.md b/.changeset/soft-goats-wash.md new file mode 100644 index 000000000..eaff57242 --- /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 000000000..0d90dae10 --- /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 653debaa8..000000000 --- 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 ef851f848..e4591f7c4 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 f14f73e42..d9b3500de 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 000000000..a3699ad04 --- /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 315d0c34a..73bb9a189 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 0dd07ef7d..1e67e0e1a 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 b41d67003..11e28b168 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 adb25ac0a..4faf47338 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 000000000..0dde1286f --- /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 000000000..995a3dc46 --- /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 000000000..8f034781d --- /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 000000000..8d05b0b5e --- /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 000000000..a28992ee6 --- /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 000000000..57417979f --- /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 ccf5ee04d..7f110bba6 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),