Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements build.format: 'preserve' #9764

Merged
merged 11 commits into from
Jan 31, 2024
18 changes: 18 additions & 0 deletions .changeset/tame-flies-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'astro': minor
---

Adds a new `build.format` configuration option: 'preserve'. This option will preserve your source structure in the final build.

The existing configuration options, `file` and `directory`, either build all of your HTML pages as files matching the route name (e.g. `/about.html`) or build all your files as `index.html` within a nested directory structure (e.g. `/about/index.html`), respectively. It was not previously possible to control the HTML file built on a per-file basis.

One limitation of `build.format: 'file'` is that it cannot create `index.html` files for any individual routes (other than the base path of `/`) while otherwise building named files. Creating explicit index pages within your file structure still generates a file named for the page route (e.g. `src/pages/about/index.astro` builds `/about.html`) when using the `file` configuration option.

Rather than make a breaking change to allow `build.format: 'file'` to be more flexible, we decided to create a new `build.format: 'preserve'`.

The new format will preserve how the filesystem is structured and make sure that is mirrored over to production. Using this option:

- `about.astro` becomes `about.html`
- `about/index.astro` becomes `about/index.html`

See the [`build.format` configuration options reference] for more details.
10 changes: 6 additions & 4 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,14 +788,15 @@ export interface AstroUserConfig {
* @default `'directory'`
* @description
* Control the output file format of each page. This value may be set by an adapter for you.
* - If `'file'`, Astro will generate an HTML file (ex: "/foo.html") for each page.
* - If `'directory'`, Astro will generate a directory with a nested `index.html` file (ex: "/foo/index.html") for each page.
* - `'file'`: Astro will generate an HTML file named for each page route. (e.g. `src/pages/about.astro` and `src/pages/about/index.astro` both build the file `/about.html`)
* - `'directory'`: Astro will generate a directory with a nested `index.html` file for each page. (e.g. `src/pages/about.astro` and `src/pages/about/index.astro` both build the file `/about/index.html`)
* - `'preserve'`: Astro will generate HTML files exactly as they appear in your source folder. (e.g. `src/pages/about.astro` builds `/about.html` and `src/pages/about/index.astro` builds the file `/about/index.html`)
*
* ```js
* {
* build: {
* // Example: Generate `page.html` instead of `page/index.html` during build.
* format: 'file'
* format: 'preserve'
* }
* }
* ```
Expand All @@ -813,7 +814,7 @@ export interface AstroUserConfig {
* - `directory` - Set `trailingSlash: 'always'`
* - `file` - Set `trailingSlash: 'never'`
*/
format?: 'file' | 'directory';
format?: 'file' | 'directory' | 'preserve';
/**
* @docs
* @name build.client
Expand Down Expand Up @@ -2637,6 +2638,7 @@ export interface RouteData {
redirect?: RedirectConfig;
redirectRoute?: RouteData;
fallbackRoutes: RouteData[];
isIndex: boolean;
}

export type RedirectRouteData = RouteData & {
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type SSRManifest = {
site?: string;
base: string;
trailingSlash: 'always' | 'never' | 'ignore';
buildFormat: 'file' | 'directory';
buildFormat: 'file' | 'directory' | 'preserve';
compressHTML: boolean;
assetsPrefix?: string;
renderers: SSRLoadedRenderer[];
Expand Down
28 changes: 25 additions & 3 deletions packages/astro/src/core/build/common.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import npath from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AstroConfig, RouteType } from '../../@types/astro.js';
import type { AstroConfig, RouteData } from '../../@types/astro.js';
import { appendForwardSlash } from '../../core/path.js';

const STATUS_CODE_PAGES = new Set(['/404', '/500']);
Expand All @@ -17,9 +17,10 @@ function getOutRoot(astroConfig: AstroConfig): URL {
export function getOutFolder(
astroConfig: AstroConfig,
pathname: string,
routeType: RouteType
routeData: RouteData
): URL {
const outRoot = getOutRoot(astroConfig);
const routeType = routeData.type;

// This is the root folder to write to.
switch (routeType) {
Expand All @@ -39,6 +40,17 @@ export function getOutFolder(
const d = pathname === '' ? pathname : npath.dirname(pathname);
return new URL('.' + appendForwardSlash(d), outRoot);
}
case 'preserve': {
let dir;
// If the pathname is '' then this is the root index.html
// If this is an index route, the folder should be the pathname, not the parent
if(pathname === '' || routeData.isIndex) {
dir = pathname;
} else {
dir = npath.dirname(pathname);
}
return new URL('.' + appendForwardSlash(dir), outRoot);
}
}
}
}
Expand All @@ -47,8 +59,9 @@ export function getOutFile(
astroConfig: AstroConfig,
outFolder: URL,
pathname: string,
routeType: RouteType
routeData: RouteData
): URL {
const routeType = routeData.type;
switch (routeType) {
case 'endpoint':
return new URL(npath.basename(pathname), outFolder);
Expand All @@ -67,6 +80,15 @@ export function getOutFile(
const baseName = npath.basename(pathname);
return new URL('./' + (baseName || 'index') + '.html', outFolder);
}
case 'preserve': {
let baseName = npath.basename(pathname);
// If there is no base name this is the root route.
// If this is an index route, the name should be `index.html`.
if(!baseName || routeData.isIndex) {
baseName = 'index';
}
return new URL(`./${baseName}.html`, outFolder);
}
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,8 +622,8 @@ async function generatePath(
body = Buffer.from(await response.arrayBuffer());
}

const outFolder = getOutFolder(pipeline.getConfig(), pathname, route.type);
const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route.type);
const outFolder = getOutFolder(pipeline.getConfig(), pathname, route);
const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route);
route.distURL = outFile;

await fs.promises.mkdir(outFolder, { recursive: true });
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ function buildManifest(
if (!route.prerender) continue;
if (!route.pathname) continue;

const outFolder = getOutFolder(opts.settings.config, route.pathname, route.type);
const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route.type);
const outFolder = getOutFolder(opts.settings.config, route.pathname, route);
const outFile = getOutFile(opts.settings.config, outFolder, route.pathname, route);
const file = outFile.toString().replace(opts.settings.config.build.client.toString(), '');
routes.push({
file,
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/build/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function shouldAppendForwardSlash(
switch (buildFormat) {
case 'directory':
return true;
case 'preserve':
case 'file':
return false;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const AstroConfigSchema = z.object({
build: z
.object({
format: z
.union([z.literal('file'), z.literal('directory')])
.union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
client: z
Expand Down Expand Up @@ -539,7 +539,7 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: string) {
build: z
.object({
format: z
.union([z.literal('file'), z.literal('directory')])
.union([z.literal('file'), z.literal('directory'), z.literal('preserve')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
client: z
Expand Down
19 changes: 8 additions & 11 deletions packages/astro/src/core/routing/manifest/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ interface Item {
routeSuffix: string;
}

interface ManifestRouteData extends RouteData {
isIndex: boolean;
}

function countOccurrences(needle: string, haystack: string) {
let count = 0;
for (const hay of haystack) {
Expand Down Expand Up @@ -193,7 +189,8 @@ function isSemanticallyEqualSegment(segmentA: RoutePart[], segmentB: RoutePart[]
* For example, `/bar` is sorted before `/foo`.
* The definition of "alphabetically" is dependent on the default locale of the running system.
*/
function routeComparator(a: ManifestRouteData, b: ManifestRouteData) {

function routeComparator(a: RouteData, b: RouteData) {
const commonLength = Math.min(a.segments.length, b.segments.length);

for (let index = 0; index < commonLength; index++) {
Expand Down Expand Up @@ -301,9 +298,9 @@ export interface CreateRouteManifestParams {
function createFileBasedRoutes(
{ settings, cwd, fsMod }: CreateRouteManifestParams,
logger: Logger
): ManifestRouteData[] {
): RouteData[] {
const components: string[] = [];
const routes: ManifestRouteData[] = [];
const routes: RouteData[] = [];
const validPageExtensions = new Set<string>([
'.astro',
...SUPPORTED_MARKDOWN_FILE_EXTENSIONS,
Expand Down Expand Up @@ -444,7 +441,7 @@ function createFileBasedRoutes(
return routes;
}

type PrioritizedRoutesData = Record<RoutePriorityOverride, ManifestRouteData[]>;
type PrioritizedRoutesData = Record<RoutePriorityOverride, RouteData[]>;

function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): PrioritizedRoutesData {
const { config } = settings;
Expand Down Expand Up @@ -690,7 +687,7 @@ export function createRouteManifest(

const redirectRoutes = createRedirectRoutes(params, routeMap, logger);

const routes: ManifestRouteData[] = [
const routes: RouteData[] = [
...injectedRoutes['legacy'].sort(routeComparator),
...[...fileBasedRoutes, ...injectedRoutes['normal'], ...redirectRoutes['normal']].sort(
routeComparator
Expand Down Expand Up @@ -727,8 +724,8 @@ export function createRouteManifest(

// In this block of code we group routes based on their locale

// A map like: locale => ManifestRouteData[]
const routesByLocale = new Map<string, ManifestRouteData[]>();
// A map like: locale => RouteData[]
const routesByLocale = new Map<string, RouteData[]>();
// This type is here only as a helper. We copy the routes and make them unique, so we don't "process" the same route twice.
// The assumption is that a route in the file system belongs to only one locale.
const setRoutes = new Set(routes.filter((route) => route.type === 'page'));
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/routing/manifest/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
fallbackRoutes: rawRouteData.fallbackRoutes.map((fallback) => {
return deserializeRouteData(fallback);
}),
isIndex: rawRouteData.isIndex,
};
}
1 change: 1 addition & 0 deletions packages/astro/src/vite-plugin-astro-server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export async function handleRoute({
type: 'fallback',
route: '',
fallbackRoutes: [],
isIndex: false,
};
renderContext = await createRenderContext({
request,
Expand Down
48 changes: 36 additions & 12 deletions packages/astro/test/astro-pageDirectoryUrl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,45 @@ import { expect } from 'chai';
import { loadFixture } from './test-utils.js';

describe('build format', () => {
let fixture;
describe('build.format: file', () => {
/** @type {import('./test-utils.js').Fixture} */
let fixture;

before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-page-directory-url',
build: {
format: 'file',
},
before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-page-directory-url',
build: {
format: 'file',
},
});
await fixture.build();
});

it('outputs', async () => {
expect(await fixture.readFile('/client.html')).to.be.ok;
expect(await fixture.readFile('/nested-md.html')).to.be.ok;
expect(await fixture.readFile('/nested-astro.html')).to.be.ok;
});
await fixture.build();
});

it('outputs', async () => {
expect(await fixture.readFile('/client.html')).to.be.ok;
expect(await fixture.readFile('/nested-md.html')).to.be.ok;
expect(await fixture.readFile('/nested-astro.html')).to.be.ok;
describe('build.format: preserve', () => {
/** @type {import('./test-utils.js').Fixture} */
let fixture;

before(async () => {
fixture = await loadFixture({
root: './fixtures/astro-page-directory-url',
build: {
format: 'preserve',
},
});
await fixture.build();
});

it('outputs', async () => {
expect(await fixture.readFile('/client.html')).to.be.ok;
expect(await fixture.readFile('/nested-md/index.html')).to.be.ok;
expect(await fixture.readFile('/nested-astro/index.html')).to.be.ok;
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---
<html>
<head><title>testing</title></head>
<body><h1>testing</h1></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
const another = new URL('./another/', Astro.url);
---
<a id="another" href={another.pathname}></a>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
---
<html>
<head><title>testing</title></head>
<body><h1>testing</h1></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head>
<title>Testing</title>
</head>
<body>
<h1>Testing</h1>
</body>
</html>
41 changes: 41 additions & 0 deletions packages/astro/test/page-format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,45 @@ describe('build.format', () => {
});
});
});

describe('preserve', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
base: '/test',
root: './fixtures/page-format/',
trailingSlash: 'always',
build: {
format: 'preserve',
},
i18n: {
locales: ['en'],
defaultLocale: 'en',
routing: {
prefixDefaultLocale: true,
redirectToDefaultLocale: true,
}
}
});
});

describe('Build', () => {
before(async () => {
await fixture.build();
});

it('relative urls created point to sibling folders', async () => {
let html = await fixture.readFile('/en/nested/page.html');
let $ = cheerio.load(html);
expect($('#another').attr('href')).to.equal('/test/en/nested/another/');
});

it('index files are written as index.html', async () => {
let html = await fixture.readFile('/en/nested/index.html');
let $ = cheerio.load(html);
expect($('h1').text()).to.equal('Testing');
});
});
});
});
Loading